smith-agents 0.11.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 +698 -0
- data/README.md +621 -15
- data/UPSTREAM_PROPOSAL.md +1 -0
- data/docs/CONFIGURATION.md +80 -14
- data/docs/PATTERNS.md +30 -13
- data/docs/PERSISTENCE.md +733 -5
- 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/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 +6 -0
- data/lib/smith/agent/provider_call_timing.rb +13 -4
- data/lib/smith/agent/provider_completion.rb +35 -32
- 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 +70 -3
- 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 -51
- data/lib/smith/agent.rb +5 -0
- 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/continuation.rb +153 -0
- data/lib/smith/budget/host_ledger.rb +137 -0
- data/lib/smith/budget/ledger.rb +4 -2
- 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 +10 -5
- data/lib/smith/context.rb +28 -0
- 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 +20 -18
- 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 +249 -2
- 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 +43 -0
- data/lib/smith/pricing/catalog_validation.rb +91 -0
- data/lib/smith/pricing.rb +95 -64
- 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 +11 -5
- 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 +61 -10
- data/lib/smith/workflow/deterministic_step.rb +55 -6
- data/lib/smith/workflow/dsl.rb +59 -0
- data/lib/smith/workflow/durability.rb +139 -30
- data/lib/smith/workflow/evaluator_optimizer.rb +12 -6
- data/lib/smith/workflow/event_integration.rb +9 -0
- data/lib/smith/workflow/execution.rb +30 -23
- data/lib/smith/workflow/execution_binding_resolution.rb +13 -0
- data/lib/smith/workflow/failure_reconstructor.rb +27 -4
- data/lib/smith/workflow/failure_record.rb +6 -1
- data/lib/smith/workflow/failure_record_validator.rb +11 -4
- 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/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/orchestrator_worker.rb +9 -3
- data/lib/smith/workflow/parallel_execution.rb +10 -0
- data/lib/smith/workflow/persistence.rb +136 -13
- data/lib/smith/workflow/remembered_calls.rb +92 -0
- data/lib/smith/workflow/retry_execution.rb +68 -9
- data/lib/smith/workflow/retry_wait.rb +134 -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 +36 -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 +113 -17
- data/lib/smith.rb +101 -45
- metadata +88 -1
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
module Providers
|
|
7
|
+
# Where a host's budget ledger (a workflow's `budget_ledger`) meets
|
|
8
|
+
# RubyLLM: each request a step's call makes over the wire (each round of
|
|
9
|
+
# a tool loop, each fallback model, each follow-up, streamed or not, and
|
|
10
|
+
# OpenAI's Responses route, which never reaches the base class) goes
|
|
11
|
+
# through `Provider#complete`. A call's first request is held before
|
|
12
|
+
# anything is sent. A later round of a tool loop continues a call whose
|
|
13
|
+
# tools already ran: it is held only when its step can pause for budget
|
|
14
|
+
# (`pausable`), and otherwise goes on, reconciled all the same. Each
|
|
15
|
+
# request is then reconciled with what it spent (its usage; its hold
|
|
16
|
+
# when it answered without usage or its stream failed part way) or, if
|
|
17
|
+
# it failed before anything came back, its hold is released. Only the
|
|
18
|
+
# call's own provider object is held, so a tool's own RubyLLM call is
|
|
19
|
+
# not. While a batch step captures its request, the hold is noted on the
|
|
20
|
+
# capture instead, for the batch to reserve.
|
|
21
|
+
module RequestHolds
|
|
22
|
+
HELD = :smith_request_holds
|
|
23
|
+
private_constant :HELD
|
|
24
|
+
|
|
25
|
+
Holder = Struct.new(:provider, :ledger, :attempt_id, :pausable, :rounds, :busy)
|
|
26
|
+
# Whether a streamed request's response began.
|
|
27
|
+
Stream = Struct.new(:started)
|
|
28
|
+
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
# Runs the block with the provider's requests held on `ledger`, keyed
|
|
32
|
+
# "<attempt_id>:<round>".
|
|
33
|
+
def within(provider, ledger, attempt_id, pausable: false, rounds: 0)
|
|
34
|
+
outer = Fiber[HELD]
|
|
35
|
+
Fiber[HELD] = Holder.new(provider, ledger, attempt_id, pausable, rounds, false)
|
|
36
|
+
yield Fiber[HELD]
|
|
37
|
+
ensure
|
|
38
|
+
Fiber[HELD] = outer
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The holder of a request the provider is about to make, unless one is
|
|
42
|
+
# under way (the base class's hook inside OpenAI's).
|
|
43
|
+
def holder_for(provider)
|
|
44
|
+
holder = Fiber[HELD]
|
|
45
|
+
holder if holder&.provider.equal?(provider) && !holder.busy
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def hold(provider, args, kwargs, mode: :sync)
|
|
49
|
+
Budget::RequestHold.for_request(
|
|
50
|
+
provider: provider.slug, model: kwargs.fetch(:model), messages: args.first, tools: kwargs[:tools],
|
|
51
|
+
schema: kwargs[:schema], params: kwargs[:params], mode:
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The request (`request`: the provider's own `complete`), held.
|
|
56
|
+
def complete(provider, args, kwargs, block, &request)
|
|
57
|
+
exchange = BatchExchange.exchange_for(provider)
|
|
58
|
+
return capture(provider, exchange, args, kwargs, block, &request) if exchange
|
|
59
|
+
|
|
60
|
+
ResponseMemo.round(provider, args.first) { send_held(provider, args, kwargs, block, &request) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def send_held(provider, args, kwargs, block, &request)
|
|
64
|
+
holder = holder_for(provider)
|
|
65
|
+
return request.call(*args, **kwargs, &block) unless holder
|
|
66
|
+
|
|
67
|
+
held(holder, hold(provider, args, kwargs), kwargs.fetch(:model).id) do |stream|
|
|
68
|
+
next request.call(*args, **kwargs) unless block
|
|
69
|
+
|
|
70
|
+
request.call(*args, **kwargs) do |chunk|
|
|
71
|
+
stream.started = true
|
|
72
|
+
block.call(chunk)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def capture(provider, exchange, args, kwargs, block, &request)
|
|
78
|
+
exchange.hold ||= hold(provider, args, kwargs, mode: :batch) if exchange.holding && exchange.result.nil?
|
|
79
|
+
request.call(*args, **kwargs, &block)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def held(holder, amount, model_id)
|
|
83
|
+
key, reserved = open_round(holder, amount)
|
|
84
|
+
stream = Stream.new(false)
|
|
85
|
+
begin
|
|
86
|
+
message = busy(holder) { yield(stream) }
|
|
87
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- an interrupted request settles too
|
|
88
|
+
settle_failed(holder, key, spent(holder.provider, model_id, e) || (amount if stream.started), reserved)
|
|
89
|
+
raise
|
|
90
|
+
end
|
|
91
|
+
Budget::HostLedger.settle(holder.ledger, key, spent(holder.provider, model_id, message) || amount)
|
|
92
|
+
message
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The request's key and whether it is held: a call's first request, or
|
|
96
|
+
# any round of a step that can pause for budget.
|
|
97
|
+
def open_round(holder, amount)
|
|
98
|
+
holder.rounds += 1
|
|
99
|
+
key = "#{holder.attempt_id}:#{holder.rounds}"
|
|
100
|
+
reserved = holder.rounds == 1 || holder.pausable
|
|
101
|
+
Budget::HostLedger.admit!(holder.ledger, amount, key) if reserved
|
|
102
|
+
[key, reserved]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def busy(holder)
|
|
106
|
+
holder.busy = true
|
|
107
|
+
yield
|
|
108
|
+
ensure
|
|
109
|
+
holder.busy = false
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# A failed request reconciles what it spent; a held one that spent
|
|
113
|
+
# nothing is released, an unheld one settles nothing.
|
|
114
|
+
def settle_failed(holder, key, spent, reserved)
|
|
115
|
+
Budget::HostLedger.settle(holder.ledger, key, spent) if spent || reserved
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# What a request spent, from its reply (or the error that reported
|
|
119
|
+
# usage), priced for the request's model; nil when it reported none.
|
|
120
|
+
def spent(provider, model_id, reply)
|
|
121
|
+
usage = Agent::ProviderUsage.from_message(reply)
|
|
122
|
+
Budget::HostLedger.amount(usage, model: model_id, provider: provider.slug) if usage
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Prepended on RubyLLM::Provider, and on RubyLLM::Providers::OpenAI
|
|
126
|
+
# outside Smith's Responses routing.
|
|
127
|
+
module Hook
|
|
128
|
+
def complete(*args, **kwargs, &block)
|
|
129
|
+
RequestHolds.complete(self, args, kwargs, block) do |*sent, **options, &chunks|
|
|
130
|
+
super(*sent, **options, &chunks)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
RubyLLM::Provider.prepend(Smith::Providers::RequestHolds::Hook)
|
|
139
|
+
RubyLLM::Providers::OpenAI.prepend(Smith::Providers::RequestHolds::Hook)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../diagnostic_text"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
module Providers
|
|
7
|
+
# The stop reason and the provider's own message id of one provider
|
|
8
|
+
# response, read from the raw body RubyLLM keeps on the message. Each
|
|
9
|
+
# provider's body is recognized by its own marker, never guessed from
|
|
10
|
+
# loose field names:
|
|
11
|
+
#
|
|
12
|
+
# Anthropic Messages "type" => "message" stop_reason, id
|
|
13
|
+
# OpenAI Chat Completions "object" => "chat.completion" choices[0].finish_reason, id
|
|
14
|
+
# OpenAI Responses "object" => "response" incomplete_details.reason, else status; id
|
|
15
|
+
# Gemini "candidates" => [...] candidates[0].finishReason, responseId
|
|
16
|
+
# Bedrock Converse "stopReason" => String stopReason
|
|
17
|
+
#
|
|
18
|
+
# OpenAI reports a refusal on the message rather than as a finish
|
|
19
|
+
# reason (Chat Completions `message.refusal`, a Responses `refusal`
|
|
20
|
+
# content item); it is read as the stop reason "refusal". Gemini blocks a
|
|
21
|
+
# prompt with `promptFeedback.blockReason` and no candidates; that is read
|
|
22
|
+
# as the stop reason "prompt_blocked".
|
|
23
|
+
#
|
|
24
|
+
# Both facts are metadata: bounded Strings, absent when the body is of
|
|
25
|
+
# none of these shapes, does not carry them, or cannot be read (streamed
|
|
26
|
+
# responses keep no raw body). Reading never raises.
|
|
27
|
+
module ResponseFacts
|
|
28
|
+
MAX_BYTES = 256
|
|
29
|
+
REFUSAL = "refusal"
|
|
30
|
+
PROMPT_BLOCKED = "prompt_blocked"
|
|
31
|
+
|
|
32
|
+
KEPT = "smith.response"
|
|
33
|
+
# A response Smith made or kept itself (a response memo's, a test
|
|
34
|
+
# stub's) carries its facts as a raw body of this shape.
|
|
35
|
+
Kept = Struct.new(:body)
|
|
36
|
+
|
|
37
|
+
READERS = [
|
|
38
|
+
[->(body) { body["object"] == KEPT }, ->(body) { [body["stop_reason"], body["id"]] }],
|
|
39
|
+
[->(body) { body["type"] == "message" }, ->(body) { [body["stop_reason"], body["id"]] }],
|
|
40
|
+
[->(body) { body["object"] == "chat.completion" },
|
|
41
|
+
lambda do |body|
|
|
42
|
+
choice = first_entry(body["choices"])
|
|
43
|
+
[(REFUSAL if chat_refusal?(choice)) || choice&.fetch("finish_reason", nil), body["id"]]
|
|
44
|
+
end],
|
|
45
|
+
[->(body) { body["object"] == "response" },
|
|
46
|
+
lambda do |body|
|
|
47
|
+
details = body["incomplete_details"]
|
|
48
|
+
reason = (REFUSAL if responses_refusal?(body["output"])) || (details["reason"] if details.is_a?(Hash))
|
|
49
|
+
[reason || body["status"], body["id"]]
|
|
50
|
+
end],
|
|
51
|
+
[->(body) { !body.key?("candidates") && body.dig("promptFeedback", "blockReason").is_a?(String) },
|
|
52
|
+
->(body) { [PROMPT_BLOCKED, body["responseId"]] }],
|
|
53
|
+
[->(body) { body["candidates"].is_a?(Array) },
|
|
54
|
+
->(body) { [first_entry(body["candidates"])&.fetch("finishReason", nil), body["responseId"]] }],
|
|
55
|
+
[->(body) { body["stopReason"].is_a?(String) }, ->(body) { [body["stopReason"], nil] }]
|
|
56
|
+
].freeze
|
|
57
|
+
private_constant :READERS
|
|
58
|
+
|
|
59
|
+
module_function
|
|
60
|
+
|
|
61
|
+
def kept(stop_reason:, id:) = Kept.new({ "object" => KEPT, "stop_reason" => stop_reason, "id" => id })
|
|
62
|
+
|
|
63
|
+
def from_message(message)
|
|
64
|
+
body = raw_body(message)
|
|
65
|
+
return {} unless body.instance_of?(Hash)
|
|
66
|
+
|
|
67
|
+
_, reader = READERS.find { |(matches, _)| matches.call(body) }
|
|
68
|
+
return {} unless reader
|
|
69
|
+
|
|
70
|
+
stop_reason, message_id = reader.call(body)
|
|
71
|
+
{ stop_reason: bounded(stop_reason), provider_message_id: bounded(message_id) }.compact
|
|
72
|
+
rescue StandardError
|
|
73
|
+
{}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def raw_body(message)
|
|
77
|
+
return unless message.respond_to?(:raw)
|
|
78
|
+
|
|
79
|
+
raw = message.raw
|
|
80
|
+
raw.respond_to?(:body) ? raw.body : raw
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def chat_refusal?(choice)
|
|
84
|
+
message = choice&.fetch("message", nil)
|
|
85
|
+
message.is_a?(Hash) && message["refusal"].is_a?(String) && !message["refusal"].empty?
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def responses_refusal?(output)
|
|
89
|
+
return false unless output.is_a?(Array)
|
|
90
|
+
|
|
91
|
+
output.any? do |item|
|
|
92
|
+
item.is_a?(Hash) && item["content"].is_a?(Array) &&
|
|
93
|
+
item["content"].any? { |part| part.is_a?(Hash) && part["type"] == REFUSAL }
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def first_entry(list)
|
|
98
|
+
list.first if list.is_a?(Array) && list.first.is_a?(Hash)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def bounded(value)
|
|
102
|
+
return unless value.is_a?(String) && !value.empty?
|
|
103
|
+
|
|
104
|
+
DiagnosticText.capture(value, max_bytes: MAX_BYTES)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "schema_rules"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
module Providers
|
|
7
|
+
# Checks a parsed structured output against the constraints in its JSON
|
|
8
|
+
# schema that SchemaConstraints may have kept from the provider:
|
|
9
|
+
# numeric bounds and multiples, string lengths, array sizes and
|
|
10
|
+
# uniqueness, and required properties, through properties, items,
|
|
11
|
+
# local `$ref`s, `allOf`, `anyOf` (one alternative must accept the
|
|
12
|
+
# value) and `oneOf` (exactly one must). When no alternative accepts a
|
|
13
|
+
# value, the problems of the one alternative of its type are reported
|
|
14
|
+
# if there is one. Each schema and value pair is judged once, so the
|
|
15
|
+
# walk stays polynomial however deeply the output nests; it reports at
|
|
16
|
+
# most MAX_PROBLEMS "path: problem" lines and stops descending after
|
|
17
|
+
# MAX_VISITS visits.
|
|
18
|
+
class SchemaConstraintCheck
|
|
19
|
+
MAX_PROBLEMS = 20
|
|
20
|
+
MAX_DEPTH = 64
|
|
21
|
+
MAX_VISITS = 1_000_000
|
|
22
|
+
include SchemaRules
|
|
23
|
+
|
|
24
|
+
def initialize(root)
|
|
25
|
+
@root = root
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def call(value)
|
|
29
|
+
@found = []
|
|
30
|
+
@visits = 0
|
|
31
|
+
@verdicts = {}
|
|
32
|
+
check(@root, value, "", 0)
|
|
33
|
+
@found.first(MAX_PROBLEMS)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def check(schema, value, path, depth)
|
|
39
|
+
return unless schema.is_a?(Hash) && depth <= MAX_DEPTH && @found.length < MAX_PROBLEMS
|
|
40
|
+
return if (@visits += 1) > MAX_VISITS
|
|
41
|
+
|
|
42
|
+
schema = resolve(schema)
|
|
43
|
+
check_alternatives(schema, value, path, depth) if schema["anyOf"] || schema["oneOf"]
|
|
44
|
+
Array(schema["allOf"]).each { check(_1, value, path, depth + 1) }
|
|
45
|
+
check_value(schema, value, path, depth)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def check_value(schema, value, path, depth)
|
|
49
|
+
own_problems(schema, value).each { |problem| report(path, problem) }
|
|
50
|
+
check_children(schema, value, path, depth)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def report(path, problem) = @found << "#{path.empty? ? "/" : path}: #{problem}"
|
|
54
|
+
|
|
55
|
+
def check_alternatives(schema, value, path, depth)
|
|
56
|
+
alternatives = Array(schema["anyOf"] || schema["oneOf"]).map { resolve(_1) }
|
|
57
|
+
accepted = alternatives.count { accepts?(_1, value, depth + 1) }
|
|
58
|
+
return if schema["anyOf"] ? accepted.positive? : accepted == 1
|
|
59
|
+
return report(path, "matches more than one of the allowed forms") if accepted > 1
|
|
60
|
+
|
|
61
|
+
report_closest(alternatives, value, path, depth)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def report_closest(alternatives, value, path, depth)
|
|
65
|
+
typed = alternatives.select { type?(_1["type"], value) }
|
|
66
|
+
return report(path, "matches none of the allowed forms") unless typed.length == 1
|
|
67
|
+
|
|
68
|
+
check(typed.first, value, path, depth + 1)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Whether the alternative accepts the value, judged once per schema
|
|
72
|
+
# and value.
|
|
73
|
+
def accepts?(schema, value, depth)
|
|
74
|
+
@verdicts.fetch([schema.object_id, value.object_id]) do |key|
|
|
75
|
+
@verdicts[key] = type?(schema["type"], value) && trial(schema, value, depth)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def trial(schema, value, depth)
|
|
80
|
+
outer = @found
|
|
81
|
+
@found = []
|
|
82
|
+
check(schema, value, "", depth)
|
|
83
|
+
@found.empty?
|
|
84
|
+
ensure
|
|
85
|
+
@found = outer
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def check_children(schema, value, path, depth)
|
|
89
|
+
case value
|
|
90
|
+
when Hash
|
|
91
|
+
Hash(schema["properties"]).each do |name, child|
|
|
92
|
+
check(child, value[name], "#{path}/#{name}", depth + 1) if value.key?(name)
|
|
93
|
+
end
|
|
94
|
+
when Array
|
|
95
|
+
value.each_with_index do |item, index|
|
|
96
|
+
check(item_schema(schema, index), item, "#{path}/#{index}", depth + 1)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def item_schema(schema, index)
|
|
102
|
+
tuple = schema["prefixItems"] || (schema["items"] if schema["items"].is_a?(Array))
|
|
103
|
+
Array(tuple)[index] || (schema["items"] unless schema["items"].is_a?(Array))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def own_problems(schema, value)
|
|
107
|
+
case value
|
|
108
|
+
when Integer, Float then number_problems(schema, value)
|
|
109
|
+
when String then size_problems(schema, value.length, "characters", "minLength", "maxLength")
|
|
110
|
+
when Array then array_problems(schema, value)
|
|
111
|
+
when Hash then Array(schema["required"]).reject { value.key?(_1) }.map { "is missing #{_1}" }
|
|
112
|
+
else []
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def number_problems(schema, number)
|
|
117
|
+
# NUMBER_RULES: [keyword, broken?(number, limit), message]
|
|
118
|
+
NUMBER_RULES.filter_map do |keyword, broken, message|
|
|
119
|
+
limit = schema[keyword]
|
|
120
|
+
format(message, limit) if numeric?(limit) && broken.call(number, limit)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# A local JSON pointer `$ref` ("#" for the whole schema,
|
|
125
|
+
# "#/$defs/name", "#/definitions/name", or deeper, as
|
|
126
|
+
# "#/$defs/a/properties/b"); any other reference is left as the schema
|
|
127
|
+
# it sits in.
|
|
128
|
+
def resolve(schema)
|
|
129
|
+
ref = schema["$ref"]
|
|
130
|
+
return @root if ref == "#"
|
|
131
|
+
return schema unless ref.is_a?(String) && ref.start_with?("#/")
|
|
132
|
+
|
|
133
|
+
target = ref.delete_prefix("#/").split("/").reduce(@root) do |level, part|
|
|
134
|
+
key = part.gsub("~1", "/").gsub("~0", "~")
|
|
135
|
+
level.is_a?(Hash) ? level[key] : nil
|
|
136
|
+
end
|
|
137
|
+
target.is_a?(Hash) ? target : schema
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "schema_constraint_check"
|
|
6
|
+
|
|
7
|
+
module Smith
|
|
8
|
+
module Providers
|
|
9
|
+
# Structured-output schemas and the constraints providers do not enforce.
|
|
10
|
+
#
|
|
11
|
+
# Anthropic's structured outputs reject numeric constraints (`minimum`,
|
|
12
|
+
# `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf`),
|
|
13
|
+
# string lengths (`minLength`, `maxLength`) and array sizes beyond a
|
|
14
|
+
# `minItems` of 0 or 1 (`maxItems`, `uniqueItems`), and need
|
|
15
|
+
# `additionalProperties: false` on every object. `for_provider` sends
|
|
16
|
+
# such a schema without them; `problems` checks them on the parsed
|
|
17
|
+
# output, for every provider (SchemaConstraintCheck), so one schema
|
|
18
|
+
# serves both the request and the check. Walks are bounded in depth.
|
|
19
|
+
module SchemaConstraints
|
|
20
|
+
STRIPPED = %w[minimum maximum exclusiveMinimum exclusiveMaximum multipleOf minLength maxLength maxItems
|
|
21
|
+
uniqueItems].freeze
|
|
22
|
+
NESTED_LISTS = %w[anyOf allOf oneOf prefixItems].freeze
|
|
23
|
+
NESTED_MAPS = %w[properties $defs definitions].freeze
|
|
24
|
+
NESTED_SCHEMAS = %w[items not].freeze
|
|
25
|
+
MAX_DEPTH = 64
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# The JSON schema payload ({ "schema" => ..., with "name" and the
|
|
30
|
+
# like when given }, String keys) of an output schema: a RubyLLM
|
|
31
|
+
# schema class or instance, or a Hash. nil when it has none.
|
|
32
|
+
def json_schema(output_schema)
|
|
33
|
+
instance = output_schema.is_a?(Class) ? output_schema.new : output_schema
|
|
34
|
+
raw = instance.respond_to?(:to_json_schema) ? instance.to_json_schema : instance
|
|
35
|
+
return unless raw.is_a?(Hash)
|
|
36
|
+
|
|
37
|
+
schema = JSON.parse(JSON.generate(raw))
|
|
38
|
+
schema["schema"].is_a?(Hash) ? schema : { "schema" => schema }
|
|
39
|
+
rescue StandardError
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The schema payload to send to `provider`: for Anthropic, without the
|
|
44
|
+
# constraints it rejects and with closed objects; unchanged otherwise.
|
|
45
|
+
def for_provider(payload, provider)
|
|
46
|
+
return payload unless provider.to_s == "anthropic"
|
|
47
|
+
|
|
48
|
+
payload.merge("schema" => strip(payload.fetch("schema"), 0))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# "path: problem" lines for each constraint the value breaks, at most
|
|
52
|
+
# SchemaConstraintCheck::MAX_PROBLEMS.
|
|
53
|
+
def problems(schema, value) = SchemaConstraintCheck.new(schema).call(value)
|
|
54
|
+
|
|
55
|
+
def strip(schema, depth)
|
|
56
|
+
return schema unless schema.is_a?(Hash) && depth <= MAX_DEPTH
|
|
57
|
+
|
|
58
|
+
copy = schema.reject { |key, setting| STRIPPED.include?(key) || (key == "minItems" && setting.to_i > 1) }
|
|
59
|
+
close_object!(copy)
|
|
60
|
+
copy.to_h { |key, setting| [key, strip_nested(key, setting, depth)] }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def strip_nested(key, setting, depth)
|
|
64
|
+
if (NESTED_LISTS.include?(key) || key == "items") && setting.is_a?(Array)
|
|
65
|
+
setting.map { strip(_1, depth + 1) }
|
|
66
|
+
elsif NESTED_MAPS.include?(key) && setting.is_a?(Hash) then setting.transform_values { strip(_1, depth + 1) }
|
|
67
|
+
elsif NESTED_SCHEMAS.include?(key) then strip(setting, depth + 1)
|
|
68
|
+
else setting
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# An object gets `additionalProperties: false`. One that allows other
|
|
73
|
+
# properties, or declares none (a free-form map, which closed could
|
|
74
|
+
# only be `{}`), cannot be sent and is refused before the call.
|
|
75
|
+
def close_object!(schema)
|
|
76
|
+
return unless Array(schema["type"]).include?("object") || schema.key?("properties")
|
|
77
|
+
|
|
78
|
+
open = schema.key?("additionalProperties") && schema["additionalProperties"] != false
|
|
79
|
+
if open || Hash(schema["properties"]).empty?
|
|
80
|
+
raise Smith::WorkflowError, "Anthropic structured outputs need every object to declare its properties " \
|
|
81
|
+
"and allow no others"
|
|
82
|
+
end
|
|
83
|
+
schema["additionalProperties"] = false
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
module Providers
|
|
7
|
+
# The JSON schema rules SchemaConstraintCheck applies: numeric bounds
|
|
8
|
+
# and multiples, array sizes and uniqueness, and JSON types.
|
|
9
|
+
module SchemaRules
|
|
10
|
+
NUMBER_RULES = [
|
|
11
|
+
["minimum", ->(number, limit) { number < limit }, "is below the minimum %s"],
|
|
12
|
+
["maximum", ->(number, limit) { number > limit }, "is above the maximum %s"],
|
|
13
|
+
["exclusiveMinimum", ->(number, limit) { number <= limit }, "must be above %s"],
|
|
14
|
+
["exclusiveMaximum", ->(number, limit) { number >= limit }, "must be below %s"],
|
|
15
|
+
["multipleOf", ->(number, divisor) { divisor.positive? && !SchemaRules.multiple?(number, divisor) },
|
|
16
|
+
"is not a multiple of %s"]
|
|
17
|
+
].freeze
|
|
18
|
+
TYPES = {
|
|
19
|
+
"object" => ->(value) { value.is_a?(Hash) }, "array" => ->(value) { value.is_a?(Array) },
|
|
20
|
+
"string" => ->(value) { value.is_a?(String) },
|
|
21
|
+
"integer" => ->(value) { value.is_a?(Integer) || SchemaRules.integral_float?(value) },
|
|
22
|
+
"number" => ->(value) { value.is_a?(Integer) || value.is_a?(Float) },
|
|
23
|
+
"boolean" => ->(value) { [true, false].include?(value) }, "null" => lambda(&:nil?)
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
# Compared as the decimals written in the schema and the output, so
|
|
27
|
+
# 5 is a multiple of 0.01 and 0.3 of 0.1.
|
|
28
|
+
def self.integral_float?(value) = value.is_a?(Float) && value.finite? && value == value.floor
|
|
29
|
+
|
|
30
|
+
def self.multiple?(number, divisor)
|
|
31
|
+
quotient = BigDecimal(number.to_s) / BigDecimal(divisor.to_s)
|
|
32
|
+
(quotient - quotient.round).abs < BigDecimal("1e-9")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def numeric?(setting) = setting.is_a?(Integer) || (setting.is_a?(Float) && setting.finite?)
|
|
38
|
+
|
|
39
|
+
def array_problems(schema, array)
|
|
40
|
+
problems = size_problems(schema, array.length, "items", "minItems", "maxItems")
|
|
41
|
+
problems << "has duplicate items" if schema["uniqueItems"] == true && duplicates?(array)
|
|
42
|
+
problems
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# 1 and 1.0 are the same JSON number.
|
|
46
|
+
def duplicates?(array)
|
|
47
|
+
normalized = array.map { |item| SchemaRules.integral_float?(item) ? item.to_i : item }
|
|
48
|
+
normalized.uniq.length != normalized.length
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def size_problems(schema, size, unit, min_key, max_key)
|
|
52
|
+
[
|
|
53
|
+
("has fewer than #{schema[min_key]} #{unit}" if schema[min_key].is_a?(Integer) && size < schema[min_key]),
|
|
54
|
+
("has more than #{schema[max_key]} #{unit}" if schema[max_key].is_a?(Integer) && size > schema[max_key])
|
|
55
|
+
].compact
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def type?(type, value)
|
|
59
|
+
return true if type.nil?
|
|
60
|
+
return Array(type).any? { type?(_1, value) } if type.is_a?(Array)
|
|
61
|
+
|
|
62
|
+
TYPES.fetch(type, ->(_value) { true }).call(value)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module Providers
|
|
5
|
+
# What a provider's stop reason says about how a response ended: cut off
|
|
6
|
+
# at the output limit (:truncated), declined (:refused), or nothing Smith
|
|
7
|
+
# acts on (nil, which includes an absent or unknown stop reason).
|
|
8
|
+
#
|
|
9
|
+
# truncated max_tokens, model_context_window_exceeded (Anthropic,
|
|
10
|
+
# Bedrock), length (OpenAI Chat Completions),
|
|
11
|
+
# max_output_tokens (OpenAI Responses), MAX_TOKENS (Gemini),
|
|
12
|
+
# output_limit_reached and stream_incomplete (Smith, for
|
|
13
|
+
# a streamed reply that ended without a stop reason, with
|
|
14
|
+
# or without its output reaching the request's limit)
|
|
15
|
+
# refused refusal (Anthropic; OpenAI's message-level refusal),
|
|
16
|
+
# prompt_blocked (a Gemini prompt block),
|
|
17
|
+
# content_filter (OpenAI), SAFETY, RECITATION, BLOCKLIST,
|
|
18
|
+
# PROHIBITED_CONTENT, SPII, IMAGE_SAFETY (Gemini),
|
|
19
|
+
# content_filtered, guardrail_intervened (Bedrock)
|
|
20
|
+
module StopReason
|
|
21
|
+
TRUNCATED = %w[
|
|
22
|
+
max_tokens model_context_window_exceeded length max_output_tokens MAX_TOKENS output_limit_reached
|
|
23
|
+
stream_incomplete
|
|
24
|
+
].freeze
|
|
25
|
+
REFUSED = %w[
|
|
26
|
+
refusal prompt_blocked content_filter SAFETY RECITATION BLOCKLIST PROHIBITED_CONTENT SPII IMAGE_SAFETY
|
|
27
|
+
content_filtered guardrail_intervened
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
module_function
|
|
31
|
+
|
|
32
|
+
def classify(stop_reason)
|
|
33
|
+
return :truncated if TRUNCATED.include?(stop_reason)
|
|
34
|
+
|
|
35
|
+
:refused if REFUSED.include?(stop_reason)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|