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
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ module Streaming
5
+ # Keeps a model's `<think>...</think>` text out of a stream. Some
6
+ # OpenAI-compatible reasoning models write their thinking into the reply
7
+ # between these tags, and RubyLLM moves it out of the message it
8
+ # accumulates; this does the same to the chunks as they come, a tag split
9
+ # across chunks included, so the stream holds the text the message will.
10
+ # Its state starts over with each provider request (`round`), as
11
+ # RubyLLM's accumulator does, and a partial tag left at a request's end
12
+ # is dropped, as there.
13
+ class ThinkTags
14
+ OPEN = "<think>"
15
+ CLOSE = "</think>"
16
+ private_constant :OPEN, :CLOSE
17
+
18
+ def initialize
19
+ @round = nil
20
+ start_over
21
+ end
22
+
23
+ # The part of `text` outside thinking (nil for anything but a String).
24
+ def strip(text, round)
25
+ start_over(round) unless round == @round
26
+ return unless text.is_a?(String)
27
+
28
+ remaining = @pending + text
29
+ @pending = +""
30
+ output = +""
31
+ remaining = consume(remaining, output) until remaining.empty?
32
+ output
33
+ end
34
+
35
+ private
36
+
37
+ def start_over(round = nil)
38
+ @round = round
39
+ @inside = false
40
+ @pending = +""
41
+ end
42
+
43
+ # Up to and past the next tag, or the rest, holding back a suffix that
44
+ # may begin the tag.
45
+ def consume(remaining, output)
46
+ tag = @inside ? CLOSE : OPEN
47
+ index = remaining.index(tag)
48
+ return hold(remaining, tag, output) unless index
49
+
50
+ output << remaining[0, index] unless @inside
51
+ @inside = !@inside
52
+ remaining[(index + tag.length)..]
53
+ end
54
+
55
+ def hold(remaining, tag, output)
56
+ held = held_back(remaining, tag)
57
+ output << remaining[0, remaining.length - held] unless @inside
58
+ @pending = remaining[remaining.length - held, held]
59
+ +""
60
+ end
61
+
62
+ def held_back(text, tag)
63
+ [text.length, tag.length - 1].min.downto(1).find { |length| text.end_with?(tag[0, length]) } || 0
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "streaming/session"
4
+ require_relative "streaming/think_tags"
5
+ require_relative "streaming/json_field"
6
+
7
+ module Smith
8
+ # A step declared with `execute Agent, stream: :name` sends its reply's
9
+ # text, as the model writes it, to a sink the host gives it. The host
10
+ # registers a callable under that name in `Smith.config.stream_sinks`;
11
+ # when the step starts, Smith calls it with the step's info (the stream's
12
+ # name, the run's execution key, the transition) and streams to the sink
13
+ # it returns, or does not stream the step when it returns nil (nobody is
14
+ # listening). Each step execution gets its own sink, so concurrent runs
15
+ # never share one.
16
+ #
17
+ # The sink is told of each provider call (`start(info)`), each text chunk
18
+ # (`delta(text)`), text Smith discards (`reset(reason)`), the step's
19
+ # accepted output once the step is stored (`finish(output)`) and its
20
+ # failure (`fail(error_class)`);
21
+ # Smith calls only the methods the sink defines. See Session. The text is
22
+ # the model's, before the step's guardrails and output checks run: a
23
+ # draft until `finish`.
24
+ #
25
+ # Streaming changes nothing durable: the step's output, usage, budgets,
26
+ # guardrails, contracts and persistence are those of the same step
27
+ # without it, and a replayed step streams again.
28
+ module Streaming
29
+ def self.check_sinks!(sinks)
30
+ raise ArgumentError, "Smith.config.stream_sinks must be a Hash of sink names to sinks" unless sinks.is_a?(Hash)
31
+
32
+ sinks.to_h { |name, sink| [checked_name!(name), checked_sink!(name, sink)] }.freeze
33
+ end
34
+
35
+ def self.checked_name!(name)
36
+ return name.to_sym if (name.is_a?(Symbol) || name.is_a?(String)) && !name.to_s.empty?
37
+
38
+ raise ArgumentError, "Smith.config.stream_sinks names must be Symbols or Strings, got #{name.inspect}"
39
+ end
40
+
41
+ def self.checked_sink!(name, sink)
42
+ return sink if sink.respond_to?(:call)
43
+
44
+ raise ArgumentError, "stream sink #{name.inspect} must be a callable that takes the step's info and returns " \
45
+ "the step's sink (or nil)"
46
+ end
47
+ private_class_method :checked_name!, :checked_sink!
48
+
49
+ # The step's Session, or nil when its sink is nil (or the callable
50
+ # raised, which is logged). StreamSinkMissing when the name is not
51
+ # registered.
52
+ def self.open(name, execution_key:, transition:)
53
+ factory = Smith.config.stream_sinks.fetch(name) { raise StreamSinkMissing, name }
54
+ info = { stream: name, execution_key: execution_key&.dup&.freeze, transition: }.freeze
55
+ sink = begin
56
+ factory.call(info)
57
+ rescue StandardError, ScriptError => e
58
+ Smith.config.logger&.error("Smith stream sink #{name.inspect} call error: #{e.class}")
59
+ nil
60
+ end
61
+ sink && Session.new(sink, info)
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ module Testing
5
+ # The checks behind the assertions and matchers: each returns nil when
6
+ # it holds, else what failed.
7
+ module Checks
8
+ module_function
9
+
10
+ def waiting_for(workflow, event = nil, kind: nil)
11
+ wait = workflow.awaiting
12
+ return "expected the run to wait, but it is #{workflow.state} and not waiting" unless wait
13
+ return other_event(event, wait) if event && wait.event.to_s != event.to_s
14
+
15
+ "expected a #{kind} wait, but the run's is a #{wait.kind} wait" if kind && wait.kind.to_s != kind.to_s
16
+ end
17
+
18
+ def other_event(event, wait)
19
+ "expected the run to wait for #{event}, but it waits for #{wait.event || "a #{wait.kind}"}"
20
+ end
21
+
22
+ def transitioned_through(workflow, names)
23
+ taken = Testing.transitions(workflow)
24
+ rest = taken
25
+ names.each do |name|
26
+ index = rest.index(name.to_sym)
27
+ return "expected the run through #{names.inspect} in order; it went through #{taken.inspect}" unless index
28
+
29
+ rest = rest.drop(index + 1)
30
+ end
31
+ nil
32
+ end
33
+
34
+ def usage_recorded(workflow, count: nil, **attributes)
35
+ attributes = usage_attributes(attributes)
36
+ entries = workflow.usage_entries
37
+ found = entries.count { |entry| attributes.all? { |name, value| same?(entry.public_send(name), value) } }
38
+ return if count ? found == count : found.positive?
39
+
40
+ "expected #{count || "a"} usage entry matching #{attributes.inspect}, found #{found} among " \
41
+ "#{entries.map { _1.to_h.slice(:agent_name, :input_tokens, :output_tokens, :mode) }.inspect}"
42
+ end
43
+
44
+ # UsageEntry attributes; an agent class stands for its registered name.
45
+ def usage_attributes(attributes)
46
+ unknown = attributes.keys - Workflow::UsageEntry.members
47
+ raise ArgumentError, "unknown usage attributes: #{unknown.join(", ")}" unless unknown.empty?
48
+
49
+ agent = attributes[:agent_name]
50
+ agent.is_a?(Class) ? attributes.merge(agent_name: agent.register_as) : attributes
51
+ end
52
+
53
+ def same?(actual, expected)
54
+ actual == expected || ((expected.is_a?(Symbol) || expected.is_a?(String)) && actual.to_s == expected.to_s)
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ module Testing
5
+ # An agent request Smith::Testing would not send: the agent has no stub
6
+ # and `Smith::Testing.unstubbed` is :refuse.
7
+ class UnstubbedCall < Smith::Error; end
8
+
9
+ # An agent's call bound to its provider object and chat, and its stub
10
+ # (nil for an agent with none, when unstubbed requests are refused).
11
+ Answering = Data.define(:stub, :provider, :chat, :agent) do
12
+ def reply(&)
13
+ return stub.reply(chat, &) if stub
14
+
15
+ raise UnstubbedCall, "#{agent || "an agent"} has no stub, and Smith::Testing.unstubbed is :refuse"
16
+ end
17
+ end
18
+
19
+ # A stubbed agent's call, bound to its provider object and chat, and
20
+ # the transitions a run completes (Workflow).
21
+ module Calls
22
+ private
23
+
24
+ def attempt_model(agent_class, ...)
25
+ with_testing_fiber(STUB, Testing.stub_for(agent_class)) { super }
26
+ end
27
+
28
+ def complete_chat(chat, model_reference, collected, facts = {})
29
+ stub = Fiber[STUB]
30
+ return super unless stub || Testing.unstubbed == :refuse
31
+
32
+ answering = Answering.new(stub:, provider: chat_provider(chat), chat:, agent: facts[:agent_name])
33
+ with_testing_fiber(ANSWERING, answering) { super }
34
+ end
35
+
36
+ def with_testing_fiber(key, value)
37
+ outer = Fiber[key]
38
+ Fiber[key] = value
39
+ yield
40
+ ensure
41
+ Fiber[key] = outer
42
+ end
43
+
44
+ # Every step a run counts passes here, on every path (a wait's
45
+ # completion and a prepared split-step's included); a failed or
46
+ # timed-out step is not a transition the run went through.
47
+ def record_step_snapshot(step_result)
48
+ if step_result.is_a?(Hash) && step_result[:transition] && !step_result[:error] && !step_result[:timed_out]
49
+ (@testing_transitions ||= []) << step_result[:transition].to_sym
50
+ end
51
+ super
52
+ end
53
+ end
54
+
55
+ # A stubbed call's requests, answered where they would go over the wire
56
+ # (RubyLLM::Provider, and Bedrock's own).
57
+ module Requests
58
+ private
59
+
60
+ def sync_response(...)
61
+ answering = Testing.answering(self)
62
+ answering ? answering.reply : super
63
+ end
64
+
65
+ def stream_response(*, **, &)
66
+ answering = Testing.answering(self)
67
+ answering ? answering.reply(&) : super
68
+ end
69
+ end
70
+
71
+ # The same for Smith's OpenAI Responses route, whose request is still
72
+ # rendered (as the chat route's is) before it is answered.
73
+ module ResponsesRequests
74
+ RENDERED = %i[tools temperature model schema thinking tool_prefs].freeze
75
+
76
+ def complete(provider, messages, **options, &)
77
+ answering = Testing.answering(provider)
78
+ return super unless answering
79
+
80
+ render_response_payload(messages, **options.slice(*RENDERED))
81
+ answering.reply(&)
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "smith/testing"
4
+
5
+ module Smith
6
+ module Testing
7
+ # Minitest assertions for workflow runs (`include
8
+ # Smith::Testing::Assertions`); stubs are dropped after each test.
9
+ #
10
+ # assert_waiting_for run, :approval
11
+ # assert_waiting_for run, kind: :batch
12
+ # assert_transitioned_through run, :triage, :draft
13
+ # assert_usage_recorded run, agent_name: Triager, input_tokens: 40
14
+ # assert_usage_recorded run, count: 2, mode: :batch
15
+ module Assertions
16
+ def assert_waiting_for(workflow, event = nil, kind: nil)
17
+ failure = Checks.waiting_for(workflow, event, kind:)
18
+ assert(failure.nil?, failure)
19
+ end
20
+
21
+ def assert_transitioned_through(workflow, *names)
22
+ failure = Checks.transitioned_through(workflow, names)
23
+ assert(failure.nil?, failure)
24
+ end
25
+
26
+ def assert_usage_recorded(workflow, count: nil, **attributes)
27
+ failure = Checks.usage_recorded(workflow, count:, **attributes)
28
+ assert(failure.nil?, failure)
29
+ end
30
+
31
+ def after_teardown
32
+ super
33
+ Testing.reset!
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "smith/testing"
4
+
5
+ module Smith
6
+ module Testing
7
+ # RSpec matchers for workflow runs, included in every example group;
8
+ # stubs are dropped after each example.
9
+ #
10
+ # expect(run).to be_waiting_for(:approval)
11
+ # expect(run).to be_waiting_for(kind: :batch)
12
+ # expect(run).to have_transitioned_through(:triage, :draft)
13
+ # expect(run).to have_recorded_usage(agent_name: Triager, input_tokens: 40)
14
+ module Matchers
15
+ # One of the Checks as a matcher.
16
+ class Matcher
17
+ attr_reader :description, :failure_message
18
+
19
+ def initialize(description, &check)
20
+ @description = description
21
+ @check = check
22
+ end
23
+
24
+ def matches?(workflow)
25
+ @failure_message = @check.call(workflow)
26
+ @failure_message.nil?
27
+ end
28
+
29
+ def failure_message_when_negated = "expected the run not to #{description}"
30
+ end
31
+
32
+ def be_waiting_for(event = nil, kind: nil)
33
+ Matcher.new("wait for #{event || kind || "anything"}") { Checks.waiting_for(_1, event, kind:) }
34
+ end
35
+
36
+ # rubocop:disable Naming/PredicatePrefix -- RSpec's have_ matcher names
37
+ def have_transitioned_through(*names)
38
+ Matcher.new("go through #{names.join(", ")}") { Checks.transitioned_through(_1, names) }
39
+ end
40
+
41
+ def have_recorded_usage(count: nil, **attributes)
42
+ Matcher.new("record usage #{attributes.inspect}") { Checks.usage_recorded(_1, count:, **attributes) }
43
+ end
44
+ # rubocop:enable Naming/PredicatePrefix
45
+ end
46
+ end
47
+ end
48
+
49
+ RSpec.configure do |config|
50
+ config.include Smith::Testing::Matchers
51
+ config.after { Smith::Testing.reset! }
52
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Smith
6
+ module Testing
7
+ # A tool request among a stub's outputs: the tool's name as the model
8
+ # sees it, and its arguments.
9
+ ToolRequest = Data.define(:name, :arguments)
10
+
11
+ # An agent's stubbed replies, and the messages each request sent.
12
+ class Stub
13
+ TOKENS = %i[input_tokens output_tokens cached_tokens cache_creation_tokens thinking_tokens].freeze
14
+ DEFAULT_USAGE = { input_tokens: 10, output_tokens: 5 }.freeze
15
+ OUTPUTS = "a stubbed output is a String, a Hash, or a tool_call (or an Array of them)"
16
+
17
+ # ArgumentError unless the stub can answer.
18
+ def self.check!(outputs, usage, stop_reason, stream)
19
+ raise ArgumentError, "stub_agent needs at least one output" if outputs.empty?
20
+ raise ArgumentError, OUTPUTS unless outputs.all? { output?(_1) }
21
+ raise ArgumentError, "a stub's usage is token counts by #{TOKENS.join(", ")}" unless usage?(usage)
22
+ raise ArgumentError, "a stub's stop_reason is a String" unless stop_reason.is_a?(String)
23
+ raise ArgumentError, "a stub's stream is a piece size" unless stream.nil? || count?(stream, positive: true)
24
+ end
25
+
26
+ def self.output?(output)
27
+ [String, Hash, ToolRequest].any? { output.is_a?(_1) } || tool_requests?(output)
28
+ end
29
+
30
+ def self.tool_requests?(output) = output.is_a?(Array) && !output.empty? && output.all?(ToolRequest)
31
+
32
+ def self.usage?(usage) = usage.is_a?(Hash) && (usage.keys - TOKENS).empty? && usage.values.all? { count?(_1) }
33
+
34
+ def self.count?(value, positive: false) = value.is_a?(Integer) && (positive ? value.positive? : !value.negative?)
35
+
36
+ def initialize(outputs:, usage:, stop_reason:, stream:)
37
+ @outputs = outputs.map { self.class.output_entry(_1) }.freeze
38
+ @usage = DEFAULT_USAGE.merge(usage).freeze
39
+ @stop_reason = stop_reason
40
+ @stream = stream
41
+ @calls = []
42
+ @lock = Mutex.new
43
+ end
44
+
45
+ # An output as the stub keeps it: a String, or the tool requests of
46
+ # one round.
47
+ def self.output_entry(output)
48
+ return output if output.is_a?(String)
49
+ return Array(output).freeze if output.is_a?(ToolRequest) || tool_requests?(output)
50
+
51
+ JSON.generate(output)
52
+ end
53
+
54
+ # The messages each request sent, in request order.
55
+ def calls = @lock.synchronize { @calls.dup }
56
+
57
+ # The reply to the next request of `chat`; a streamed one's text is
58
+ # yielded as chunks first.
59
+ def reply(chat)
60
+ output, index = take(chat.messages)
61
+ message = output.is_a?(String) ? text(output, chat, index) : tools(output, chat, index)
62
+ pieces(message.content).each { yield chunk(_1, message) } if block_given?
63
+ message
64
+ end
65
+
66
+ private
67
+
68
+ def take(messages)
69
+ @lock.synchronize do
70
+ @calls << messages.dup.freeze
71
+ [@outputs[[@calls.length, @outputs.length].min - 1], @calls.length]
72
+ end
73
+ end
74
+
75
+ def text(content, chat, index) = reply_message(chat, index, @stop_reason, content:)
76
+
77
+ # A request for tools the chat has (RubyLLM would tell the model a
78
+ # tool it lacks is missing, and go on).
79
+ def tools(requests, chat, index)
80
+ check_tools!(requests, chat.tools.keys.map(&:to_s))
81
+ calls = requests.each_with_index.to_h do |request, position|
82
+ id = "toolu_stub_#{index}_#{position + 1}"
83
+ [id, RubyLLM::ToolCall.new(id:, name: request.name, arguments: request.arguments)]
84
+ end
85
+ reply_message(chat, index, "tool_use", content: "", tool_calls: calls)
86
+ end
87
+
88
+ def check_tools!(requests, known)
89
+ unknown = requests.map(&:name).find { !known.include?(_1) }
90
+ return unless unknown
91
+
92
+ has = known.empty? ? "it has none" : "it has #{known.join(", ")}"
93
+ raise ArgumentError, "a stubbed tool request names #{unknown}, a tool the agent does not have (#{has})"
94
+ end
95
+
96
+ def reply_message(chat, index, stop_reason, **parts)
97
+ RubyLLM::Message.new(role: :assistant, model_id: chat.model.id, **parts, **@usage,
98
+ raw: Providers::ResponseFacts.kept(stop_reason:, id: "msg_stub_#{index}"))
99
+ end
100
+
101
+ def chunk(text, message) = RubyLLM::Chunk.new(role: :assistant, content: text, model_id: message.model_id)
102
+
103
+ def pieces(text)
104
+ return [] if text.to_s.empty?
105
+
106
+ @stream ? text.each_char.each_slice(@stream).map(&:join) : [text]
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "smith"
5
+ require_relative "testing/stub"
6
+ require_relative "testing/checks"
7
+ require_relative "testing/hooks"
8
+
9
+ module Smith
10
+ # Testing a host's workflows without a provider (`require "smith/testing"`).
11
+ #
12
+ # Smith::Testing.stub_agent(Summarizer, outputs: [Smith::Testing.tool_call(Lookup, id: 7),
13
+ # "A short summary."],
14
+ # usage: { input_tokens: 40, output_tokens: 8 })
15
+ # run = Review.new(context: {})
16
+ # Smith::Testing.run_until_waiting(run)
17
+ # Smith::Testing.resume_with(run, payload: { approved: true })
18
+ #
19
+ # A stubbed agent's requests are answered where they would go over the
20
+ # wire, so everything above runs as for a provider's reply: the tool loop,
21
+ # budget holds, the response memo, streaming, output checks and repairs,
22
+ # usage entries and budgets. Each request takes the next of its outputs
23
+ # (the last answers every request after it): a String reply, a Hash as
24
+ # JSON text (for an agent with an output schema), or a tool request
25
+ # (`tool_call`, or an Array of them), which runs the agent's own tools
26
+ # before its next request. Replies carry the stub's usage and stop
27
+ # reason; a streamed one reaches its sink in pieces of `stream:`
28
+ # characters. A batch step's request is captured as ever (the batch
29
+ # queue gets it), and its delivery (`batch_payload`) answers it.
30
+ #
31
+ # Only the agent's own requests are answered: a tool's own provider call
32
+ # is not. An agent with no stub goes over the wire, unless `unstubbed =
33
+ # :refuse` fails its call first. Stubs are the process's: `reset!` drops them (smith/testing/
34
+ # minitest and smith/testing/rspec do after each test). The chat is still
35
+ # built, so the agent's provider must be configured (any key).
36
+ # Smith::Testing::Checks are what the Minitest assertions and RSpec
37
+ # matchers check.
38
+ module Testing
39
+ STUB = :smith_testing_stub
40
+ ANSWERING = :smith_testing_answering
41
+ private_constant :STUB, :ANSWERING
42
+
43
+ UNSTUBBED = %i[send refuse].freeze
44
+
45
+ @stubs = {}
46
+ @stubs_lock = Mutex.new
47
+ @unstubbed = :send
48
+
49
+ module_function
50
+
51
+ # What an agent request without a stub does: :send (the default) sends
52
+ # it over the wire; :refuse fails its call with UnstubbedCall before
53
+ # anything is sent (a mistyped stub then cannot reach a paid provider).
54
+ def unstubbed = @unstubbed
55
+
56
+ def unstubbed=(mode)
57
+ raise ArgumentError, "Smith::Testing.unstubbed is :send or :refuse" unless UNSTUBBED.include?(mode)
58
+
59
+ @unstubbed = mode
60
+ end
61
+
62
+ # Answers `agent`'s requests (its class, or its registered name) with
63
+ # `outputs`; returns the Stub.
64
+ def stub_agent(agent, outputs:, usage: {}, stop_reason: "end_turn", stream: nil)
65
+ outputs = [outputs] unless outputs.is_a?(Array)
66
+ Stub.check!(outputs, usage, stop_reason, stream)
67
+ stub = Stub.new(outputs:, usage:, stop_reason:, stream:)
68
+ @stubs_lock.synchronize { @stubs[agent.is_a?(Module) ? agent : agent.to_sym] = stub }
69
+ end
70
+
71
+ # A tool request, as a stub's output: the model asks for `tool` (its
72
+ # class, or its name as the model sees it) with `arguments`.
73
+ def tool_call(tool, **arguments)
74
+ name = tool.is_a?(Class) ? tool.allocate.name : tool.to_s
75
+ ToolRequest.new(name:, arguments: JSON.parse(JSON.generate(arguments)))
76
+ end
77
+
78
+ # The stub for `agent_class`, by class or registered name.
79
+ def stub_for(agent_class)
80
+ @stubs_lock.synchronize { @stubs[agent_class] || @stubs[agent_class.register_as&.to_sym] }
81
+ end
82
+
83
+ def reset! = @stubs_lock.synchronize { @stubs.clear }
84
+
85
+ # The stubbed call that answers `provider`'s request now, or nil: only
86
+ # the call's own provider object, and never a batch capture or delivery
87
+ # (Providers::BatchExchange answers those).
88
+ def answering(provider)
89
+ answering = Fiber[ANSWERING]
90
+ answering if answering&.provider.equal?(provider) && !Providers::BatchExchange.exchange_for(provider)
91
+ end
92
+
93
+ # The transitions the workflow instance completed, in order.
94
+ def transitions(workflow) = (workflow.instance_variable_get(:@testing_transitions) || []).dup
95
+
96
+ # Runs the workflow until it waits or ends; returns its wait, or nil. A
97
+ # durable retry's backoff is ended at once unless `retries: false`.
98
+ def run_until_waiting(workflow, retries: true)
99
+ loop do
100
+ workflow.run!
101
+ wait = workflow.awaiting
102
+ return wait unless retries && wait&.kind == :retry
103
+
104
+ workflow.expire_wait!(token: wait.token, now: wait.deadline_at + 1)
105
+ end
106
+ end
107
+
108
+ # Delivers `payload` to the workflow's wait (its event, unless `event`)
109
+ # and runs it on until it waits or ends; returns what `resume!` did.
110
+ def resume_with(workflow, payload: nil, event: nil)
111
+ wait = waiting!(workflow)
112
+ outcome = workflow.resume!(event: event || wait.event, token: wait.token, payload:)
113
+ run_until_waiting(workflow) unless outcome == :stale
114
+ outcome
115
+ end
116
+
117
+ # Ends the workflow's wait as if its deadline had passed and runs it on;
118
+ # returns what `expire_wait!` did.
119
+ def expire(workflow)
120
+ wait = waiting!(workflow)
121
+ outcome = workflow.expire_wait!(token: wait.token, now: wait.deadline_at + 1)
122
+ run_until_waiting(workflow) if outcome == :expired
123
+ outcome
124
+ end
125
+
126
+ def waiting!(workflow) = workflow.awaiting || raise(ArgumentError, "the run is not waiting")
127
+ private_class_method :waiting!
128
+
129
+ # A batch result as the provider's results file gives it: a succeeded
130
+ # one with `output` (a String, or a Hash as JSON) as its reply, or
131
+ # `type: "errored"`, "canceled" or "expired".
132
+ def batch_result(output = "", type: "succeeded", usage: {}, stop_reason: "end_turn")
133
+ return { "type" => type } unless type == "succeeded"
134
+
135
+ text = output.is_a?(String) ? output : JSON.generate(output)
136
+ tokens = { "input_tokens" => usage.fetch(:input_tokens, 10), "output_tokens" => usage.fetch(:output_tokens, 5) }
137
+ { "type" => "succeeded",
138
+ "message" => { "id" => "msg_batch", "type" => "message", "role" => "assistant",
139
+ "content" => [{ "type" => "text", "text" => text }], "stop_reason" => stop_reason,
140
+ "usage" => tokens } }
141
+ end
142
+
143
+ # The delivery for a waiting batch step: `results` is one result for an
144
+ # agent step, or a fan-out's results by branch key.
145
+ def batch_payload(workflow, results)
146
+ token = waiting!(workflow).token
147
+ results = JSON.parse(JSON.generate(results))
148
+ return { token => results } if results.key?("type")
149
+
150
+ results.to_h { |branch_key, result| [Batch.custom_id(token, branch_key), result] }
151
+ end
152
+ end
153
+ end
154
+
155
+ Smith::Workflow.prepend(Smith::Testing::Calls)
156
+ RubyLLM::Provider.prepend(Smith::Testing::Requests)
157
+ RubyLLM::Providers::Bedrock.prepend(Smith::Testing::Requests)
158
+ Smith::Providers::OpenAI::Responses.singleton_class.prepend(Smith::Testing::ResponsesRequests)
@@ -16,7 +16,8 @@ module Smith
16
16
  current_invocation_batch_admitter: :smith_tool_invocation_batch_admitter,
17
17
  current_invocation_failure_handler: :smith_tool_invocation_failure_handler,
18
18
  current_invocation_sequence: :smith_tool_invocation_sequence,
19
- current_invocation: :smith_tool_invocation
19
+ current_invocation: :smith_tool_invocation,
20
+ current_idempotency_scope: :smith_idempotency_scope
20
21
  }.freeze
21
22
 
22
23
  CONTEXT_KEYS.each do |reader, key|
@@ -24,17 +25,13 @@ module Smith
24
25
  define_method("#{reader}=") { |value| Thread.current[key] = value }
25
26
  end
26
27
 
27
- private :current_invocation_batch_admitter,
28
- :current_invocation_batch_admitter=,
29
- :current_invocation_failure_handler,
30
- :current_invocation_failure_handler=,
31
- :current_invocation_sequence,
32
- :current_invocation_sequence=,
33
- :current_tool_dispatch_claim,
34
- :current_tool_dispatch_claim=,
35
- :current_tool_dispatch_start_handler,
36
- :current_tool_dispatch_start_handler=,
37
- :current_invocation=
28
+ private :current_invocation_batch_admitter, :current_invocation_batch_admitter=,
29
+ :current_invocation_failure_handler, :current_invocation_failure_handler=,
30
+ :current_invocation_sequence, :current_invocation_sequence=,
31
+ :current_tool_dispatch_claim, :current_tool_dispatch_claim=,
32
+ :current_tool_dispatch_start_handler, :current_tool_dispatch_start_handler=,
33
+ :current_invocation=,
34
+ :current_idempotency_scope, :current_idempotency_scope=
38
35
 
39
36
  def self.capture
40
37
  CONTEXT_KEYS.to_h { |reader, key| [reader, Thread.current[key]] }.freeze