smith-agents 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +901 -0
- data/README.md +636 -20
- data/UPSTREAM_PROPOSAL.md +1 -0
- data/docs/CONFIGURATION.md +100 -14
- data/docs/PATTERNS.md +88 -12
- data/docs/PERSISTENCE.md +738 -6
- data/docs/TOOLS_AND_GUARDRAILS.md +24 -3
- data/lib/generators/smith/install/install_generator.rb +63 -2
- data/lib/generators/smith/install/templates/create_workflow_states.rb.tt +31 -0
- data/lib/generators/smith/install/templates/smith.rb.tt +39 -8
- data/lib/generators/smith/install/templates/workflow_state.rb.tt +19 -0
- data/lib/smith/agent/completion.rb +32 -6
- data/lib/smith/agent/completion_usage_recording.rb +23 -14
- data/lib/smith/agent/content_bytes.rb +98 -0
- data/lib/smith/agent/content_parts.rb +149 -0
- data/lib/smith/agent/fallback_configuration.rb +26 -3
- data/lib/smith/agent/invocation_preparation.rb +37 -14
- data/lib/smith/agent/lifecycle.rb +16 -5
- data/lib/smith/agent/observed_model.rb +29 -0
- data/lib/smith/agent/output_contract_check.rb +53 -0
- data/lib/smith/agent/output_integrity.rb +137 -0
- data/lib/smith/agent/output_policy.rb +92 -0
- data/lib/smith/agent/output_recovery.rb +129 -0
- data/lib/smith/agent/provider_attempt.rb +17 -4
- data/lib/smith/agent/provider_call_timing.rb +71 -7
- data/lib/smith/agent/provider_completion.rb +41 -38
- data/lib/smith/agent/provider_failure_handling.rb +5 -1
- data/lib/smith/agent/provider_request.rb +36 -0
- data/lib/smith/agent/provider_streaming.rb +155 -0
- data/lib/smith/agent/provider_usage.rb +75 -2
- data/lib/smith/agent/usage_entry_recording.rb +4 -2
- data/lib/smith/agent/usage_traces.rb +9 -2
- data/lib/smith/agent/usage_tracking.rb +64 -48
- data/lib/smith/agent.rb +14 -1
- data/lib/smith/artifacts/active_record_store.rb +79 -0
- data/lib/smith/artifacts/file.rb +45 -18
- data/lib/smith/artifacts/memory.rb +47 -36
- data/lib/smith/artifacts/s3.rb +126 -0
- data/lib/smith/artifacts/scoped_store.rb +55 -7
- data/lib/smith/artifacts/store_rules.rb +49 -0
- data/lib/smith/artifacts.rb +98 -1
- data/lib/smith/batch/message_shape.rb +36 -0
- data/lib/smith/batch.rb +166 -0
- data/lib/smith/budget/amount.rb +25 -0
- data/lib/smith/budget/amount_representation.rb +20 -0
- data/lib/smith/budget/continuation.rb +153 -0
- data/lib/smith/budget/host_ledger.rb +137 -0
- data/lib/smith/budget/ledger.rb +10 -4
- data/lib/smith/budget/ledger_state_transition.rb +7 -5
- data/lib/smith/budget/request_hold.rb +211 -0
- data/lib/smith/budget/spent_reservation.rb +18 -0
- data/lib/smith/child_run.rb +41 -0
- data/lib/smith/child_runs_failed.rb +56 -0
- data/lib/smith/content.rb +96 -0
- data/lib/smith/context/session.rb +14 -2
- data/lib/smith/context/state_injection.rb +21 -6
- data/lib/smith/context.rb +28 -0
- data/lib/smith/diagnostic_text.rb +10 -1
- data/lib/smith/doctor/checks/artifacts.rb +103 -0
- data/lib/smith/doctor/checks/configuration.rb +0 -11
- data/lib/smith/doctor/checks/durability.rb +35 -17
- data/lib/smith/doctor/checks/persistence_capabilities.rb +13 -7
- data/lib/smith/doctor/checks/state_model.rb +63 -0
- data/lib/smith/doctor/installer.rb +9 -2
- data/lib/smith/doctor.rb +4 -0
- data/lib/smith/errors.rb +304 -6
- data/lib/smith/idempotency_key/canonical.rb +74 -0
- data/lib/smith/idempotency_key.rb +145 -0
- data/lib/smith/launchers/inline.rb +46 -0
- data/lib/smith/message.rb +76 -0
- data/lib/smith/models/tool_routing.rb +47 -44
- data/lib/smith/output_failure.rb +130 -0
- data/lib/smith/persistence_adapters/active_record_exact_store.rb +51 -3
- data/lib/smith/persistence_adapters/active_record_store.rb +4 -7
- data/lib/smith/persistence_adapters.rb +10 -0
- data/lib/smith/pricing/catalog_validation.rb +91 -0
- data/lib/smith/pricing.rb +95 -64
- data/lib/smith/provider_permanent_failure.rb +44 -0
- data/lib/smith/providers/anthropic/batches.rb +116 -0
- data/lib/smith/providers/batch_exchange.rb +77 -0
- data/lib/smith/providers/openai/responses.rb +45 -20
- data/lib/smith/providers/output_limit.rb +68 -0
- data/lib/smith/providers/request_holds.rb +139 -0
- data/lib/smith/providers/response_facts.rb +108 -0
- data/lib/smith/providers/schema_constraint_check.rb +141 -0
- data/lib/smith/providers/schema_constraints.rb +87 -0
- data/lib/smith/providers/schema_rules.rb +66 -0
- data/lib/smith/providers/stop_reason.rb +39 -0
- data/lib/smith/providers/stream_facts.rb +192 -0
- data/lib/smith/response_memo.rb +152 -0
- data/lib/smith/setting_constructors.rb +47 -0
- data/lib/smith/streaming/json_field.rb +136 -0
- data/lib/smith/streaming/json_string.rb +91 -0
- data/lib/smith/streaming/session.rb +77 -0
- data/lib/smith/streaming/think_tags.rb +67 -0
- data/lib/smith/streaming.rb +64 -0
- data/lib/smith/testing/checks.rb +58 -0
- data/lib/smith/testing/hooks.rb +85 -0
- data/lib/smith/testing/minitest.rb +37 -0
- data/lib/smith/testing/rspec.rb +52 -0
- data/lib/smith/testing/stub.rb +110 -0
- data/lib/smith/testing.rb +158 -0
- data/lib/smith/tool/scoped_context.rb +9 -12
- data/lib/smith/tool.rb +14 -0
- data/lib/smith/trace/logger.rb +1 -1
- data/lib/smith/trace/memory.rb +1 -1
- data/lib/smith/trace/open_telemetry.rb +1 -1
- data/lib/smith/trace.rb +11 -4
- data/lib/smith/untrusted.rb +74 -0
- data/lib/smith/version.rb +2 -2
- data/lib/smith/workflow/active_clock.rb +61 -0
- data/lib/smith/workflow/agent_result.rb +37 -0
- data/lib/smith/workflow/artifact_integration.rb +57 -7
- data/lib/smith/workflow/await_step.rb +42 -0
- data/lib/smith/workflow/batch_charges.rb +74 -0
- data/lib/smith/workflow/batch_execution.rb +149 -0
- data/lib/smith/workflow/batch_holds.rb +73 -0
- data/lib/smith/workflow/batch_wait.rb +134 -0
- data/lib/smith/workflow/budget_integration.rb +14 -8
- data/lib/smith/workflow/budget_pause.rb +51 -0
- data/lib/smith/workflow/child_run_declarations.rb +127 -0
- data/lib/smith/workflow/child_run_execution.rb +150 -0
- data/lib/smith/workflow/child_run_results.rb +55 -0
- data/lib/smith/workflow/composite/effects.rb +37 -6
- data/lib/smith/workflow/composite/error.rb +1 -1
- data/lib/smith/workflow/composite/error_evidence.rb +9 -9
- data/lib/smith/workflow/composite/input.rb +11 -4
- data/lib/smith/workflow/context_keys.rb +77 -0
- data/lib/smith/workflow/deadline_enforcement.rb +1 -1
- data/lib/smith/workflow/deterministic_execution.rb +62 -11
- data/lib/smith/workflow/deterministic_step.rb +56 -7
- data/lib/smith/workflow/dsl.rb +74 -5
- data/lib/smith/workflow/durability.rb +140 -31
- data/lib/smith/workflow/evaluator_optimizer.rb +116 -12
- data/lib/smith/workflow/event_integration.rb +9 -0
- data/lib/smith/workflow/execution.rb +32 -23
- data/lib/smith/workflow/execution_binding_resolution.rb +13 -0
- data/lib/smith/workflow/failure_reconstructor.rb +41 -2
- data/lib/smith/workflow/failure_record.rb +12 -12
- data/lib/smith/workflow/failure_record_validator.rb +32 -5
- data/lib/smith/workflow/fanout_execution.rb +5 -8
- data/lib/smith/workflow/graph/batch_diagnostic.rb +83 -0
- data/lib/smith/workflow/graph/branch_limit_diagnostic.rb +41 -0
- data/lib/smith/workflow/graph/budget_ledger_diagnostic.rb +126 -0
- data/lib/smith/workflow/graph/durable_retry_diagnostic.rb +50 -0
- data/lib/smith/workflow/graph/execution_successors.rb +16 -2
- data/lib/smith/workflow/graph/inherited_untrusted_diagnostics.rb +77 -0
- data/lib/smith/workflow/graph/isolated_input_diagnostic.rb +50 -0
- data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +43 -4
- data/lib/smith/workflow/graph/output_key_diagnostic.rb +53 -0
- data/lib/smith/workflow/graph/output_schema_diagnostic.rb +49 -0
- data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +1 -0
- data/lib/smith/workflow/graph/run_launcher_diagnostic.rb +30 -0
- data/lib/smith/workflow/graph/runtime_binding_diagnostic_builder.rb +9 -3
- data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +7 -3
- data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +8 -1
- data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +9 -2
- data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +7 -4
- data/lib/smith/workflow/graph/stream_diagnostic.rb +95 -0
- data/lib/smith/workflow/graph/transition_contract.rb +19 -0
- data/lib/smith/workflow/graph/transition_contract_attributes.rb +25 -2
- data/lib/smith/workflow/graph/transition_contract_configurations.rb +24 -3
- data/lib/smith/workflow/graph/transition_diagnostics.rb +11 -24
- data/lib/smith/workflow/graph/transition_snapshot.rb +40 -39
- data/lib/smith/workflow/graph/untrusted_input_diagnostic.rb +57 -0
- data/lib/smith/workflow/graph/waiting_child_diagnostic.rb +24 -0
- data/lib/smith/workflow/graph.rb +12 -0
- data/lib/smith/workflow/guarded_step_execution.rb +48 -3
- data/lib/smith/workflow/guardrail_integration.rb +2 -0
- data/lib/smith/workflow/message_batch.rb +6 -0
- data/lib/smith/workflow/message_roles.rb +25 -0
- data/lib/smith/workflow/message_value_normalizer.rb +2 -1
- data/lib/smith/workflow/nested_execution.rb +47 -4
- data/lib/smith/workflow/optimization_state.rb +2 -2
- data/lib/smith/workflow/orchestrator_worker.rb +9 -3
- data/lib/smith/workflow/parallel_execution.rb +10 -0
- data/lib/smith/workflow/persistence.rb +162 -22
- data/lib/smith/workflow/remembered_calls.rb +92 -0
- data/lib/smith/workflow/retry_execution.rb +71 -8
- data/lib/smith/workflow/retry_wait.rb +134 -0
- data/lib/smith/workflow/run_result.rb +10 -0
- data/lib/smith/workflow/split_step_persistence/checkpoint.rb +3 -0
- data/lib/smith/workflow/split_step_persistence/checkpoint_state.rb +7 -0
- data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +4 -1
- data/lib/smith/workflow/split_step_persistence/composite_preparation.rb +3 -1
- data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +1 -0
- data/lib/smith/workflow/split_step_persistence/preparation.rb +5 -0
- data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +1 -0
- data/lib/smith/workflow/step_completion.rb +40 -7
- data/lib/smith/workflow/step_context.rb +21 -0
- data/lib/smith/workflow/step_io.rb +132 -0
- data/lib/smith/workflow/step_stream.rb +52 -0
- data/lib/smith/workflow/thread_context_snapshot.rb +2 -0
- data/lib/smith/workflow/transition.rb +267 -13
- data/lib/smith/workflow/transition_actionability.rb +2 -0
- data/lib/smith/workflow/usage_entry.rb +24 -3
- data/lib/smith/workflow/wait_completion.rb +131 -0
- data/lib/smith/workflow/wait_execution.rb +180 -0
- data/lib/smith/workflow/wait_record.rb +157 -0
- data/lib/smith/workflow.rb +117 -19
- data/lib/smith.rb +103 -47
- 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
|
-
:
|
|
29
|
-
:
|
|
30
|
-
:
|
|
31
|
-
:
|
|
32
|
-
:
|
|
33
|
-
:
|
|
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
|