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
|
@@ -2,27 +2,75 @@
|
|
|
2
2
|
|
|
3
3
|
module Smith
|
|
4
4
|
module Artifacts
|
|
5
|
+
# A run's view of the artifact store: it stores under its own
|
|
6
|
+
# namespace, and reads its own refs and those of the namespaces it may
|
|
7
|
+
# read (a child run's parent and that parent's ancestors). A store that
|
|
8
|
+
# records who stored a ref (`stored_by?`, as every built-in store does)
|
|
9
|
+
# is asked, so a ref names an artifact one of those runs stored and no
|
|
10
|
+
# other; with any other store the namespace prefix alone decides a read,
|
|
11
|
+
# and a delete is refused.
|
|
12
|
+
# Without a `backend:`, the configured store is resolved when a step
|
|
13
|
+
# first stores or reads, so a run that keeps no artifacts needs none.
|
|
5
14
|
class ScopedStore
|
|
6
|
-
def initialize(
|
|
15
|
+
def initialize(namespace:, backend: nil, readable: [])
|
|
7
16
|
@backend = backend
|
|
8
17
|
@namespace = namespace
|
|
18
|
+
@readable = [namespace, *readable].freeze
|
|
9
19
|
end
|
|
10
20
|
|
|
11
21
|
def store(data, content_type: "application/octet-stream")
|
|
12
|
-
inner_ref =
|
|
22
|
+
inner_ref = backend.store(data, content_type: content_type, execution_namespace: @namespace)
|
|
13
23
|
"#{@namespace}:#{inner_ref}"
|
|
14
24
|
end
|
|
15
25
|
|
|
16
26
|
def fetch(ref)
|
|
17
|
-
return
|
|
27
|
+
return unless ref.is_a?(String)
|
|
18
28
|
|
|
19
|
-
|
|
20
|
-
|
|
29
|
+
namespace = @readable.find { |candidate| ref.start_with?("#{candidate}:") }
|
|
30
|
+
return nil unless namespace
|
|
31
|
+
|
|
32
|
+
inner = ref.delete_prefix("#{namespace}:")
|
|
33
|
+
return nil if vouches? && !backend.stored_by?(inner, namespace)
|
|
34
|
+
|
|
35
|
+
backend.fetch(inner)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Removes one of this run's own refs (never a readable ancestor's or
|
|
39
|
+
# another run's): only through a store that can tell which run stored
|
|
40
|
+
# a ref. A store whose delete takes the run (every built-in one) is
|
|
41
|
+
# asked to remove only what this run stored, and Memory drops only
|
|
42
|
+
# this run's claim on bytes other runs share; any other store deletes
|
|
43
|
+
# a ref this run stored.
|
|
44
|
+
def delete(ref)
|
|
45
|
+
unless vouches? && backend.respond_to?(:delete)
|
|
46
|
+
raise Smith::Error, "deleting a run's artifact needs a store with stored_by? and delete"
|
|
47
|
+
end
|
|
48
|
+
return unless ref.is_a?(String)
|
|
49
|
+
|
|
50
|
+
inner = ref.delete_prefix("#{@namespace}:")
|
|
51
|
+
if per_run_delete? then backend.delete(inner, execution_namespace: @namespace)
|
|
52
|
+
elsif backend.stored_by?(inner, @namespace) then backend.delete(inner)
|
|
53
|
+
end
|
|
54
|
+
nil
|
|
21
55
|
end
|
|
22
56
|
|
|
57
|
+
# Whether the store can tell which run stored a ref.
|
|
58
|
+
def vouches? = backend.respond_to?(:stored_by?)
|
|
59
|
+
|
|
23
60
|
def expired(retention: nil)
|
|
24
|
-
|
|
25
|
-
|
|
61
|
+
raise Smith::Error, "the artifact store does not list expired refs" unless backend.respond_to?(:expired)
|
|
62
|
+
|
|
63
|
+
backend.expired(retention: retention, execution_namespace: @namespace).map { |ref| "#{@namespace}:#{ref}" }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def backend = @backend ||= Smith.artifact_backend
|
|
69
|
+
|
|
70
|
+
def per_run_delete?
|
|
71
|
+
backend.method(:delete).parameters.any? do |kind, name|
|
|
72
|
+
%i[key keyreq].include?(kind) && name == :execution_namespace
|
|
73
|
+
end
|
|
26
74
|
end
|
|
27
75
|
end
|
|
28
76
|
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module Artifacts
|
|
5
|
+
# What every built-in store holds to: a tenant `namespace:` of letters,
|
|
6
|
+
# digits, `-` and `_` (so its refs stay readable), the
|
|
7
|
+
# `artifact_tenant_isolation` setting (refuse to work without a
|
|
8
|
+
# namespace), refs that are the store's own (its tenant's prefix, then
|
|
9
|
+
# the store's ref shape, nothing else), the `artifact_retention`
|
|
10
|
+
# setting when no retention is given, and no run behind a ref whose run
|
|
11
|
+
# is not named.
|
|
12
|
+
module StoreRules
|
|
13
|
+
NAMESPACE = /\A[A-Za-z0-9_-]+\z/
|
|
14
|
+
UUID = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def check_namespace!(namespace)
|
|
19
|
+
return namespace if namespace.nil? || (namespace.is_a?(String) && NAMESPACE.match?(namespace))
|
|
20
|
+
|
|
21
|
+
raise ArgumentError, "an artifact store's namespace: must be letters, digits, - and _"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def enforce_tenant_isolation!
|
|
25
|
+
return unless Smith.config.artifact_tenant_isolation
|
|
26
|
+
|
|
27
|
+
raise Smith::Error, "artifact_tenant_isolation requires a namespace" unless @namespace
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The store's tenant prefix, then a ref of the store's shape.
|
|
31
|
+
def owns_ref?(ref)
|
|
32
|
+
return false unless ref.is_a?(String)
|
|
33
|
+
|
|
34
|
+
inner = @namespace ? ref.delete_prefix("#{@namespace}:") : ref
|
|
35
|
+
(!@namespace || inner != ref) && self.class::INNER_REF.match?(inner)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def effective_retention(retention) = retention || Smith.config.artifact_retention
|
|
39
|
+
|
|
40
|
+
# An artifact is bytes: other values would be stored as whatever a
|
|
41
|
+
# store makes of them (Ruby's inspect, or a refusal).
|
|
42
|
+
def check_data!(data)
|
|
43
|
+
raise TypeError, "an artifact is bytes (a String); serialize other values first" unless data.is_a?(String)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def generate_ref(raw) = @namespace ? "#{@namespace}:#{raw}" : raw
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
data/lib/smith/artifacts.rb
CHANGED
|
@@ -1,5 +1,102 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Smith
|
|
4
|
-
|
|
4
|
+
# Where steps keep payloads too large to keep inline, and the documents
|
|
5
|
+
# and images content parts name. Like workflow state, artifacts must
|
|
6
|
+
# outlive the process that stored them whenever a run waits, retries or
|
|
7
|
+
# resumes elsewhere, so the store is configured, never assumed:
|
|
8
|
+
#
|
|
9
|
+
# Smith.configure do |config|
|
|
10
|
+
# config.artifact_store = :s3 # or :active_record, :file, :memory, or a store
|
|
11
|
+
# config.artifact_options = { bucket: "smith-artifacts",
|
|
12
|
+
# endpoint: "https://<account>.r2.cloudflarestorage.com" }
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# With nothing configured, `Smith.artifacts` raises
|
|
16
|
+
# Smith::ArtifactStoreMissing, except in `test_mode`, where it is an
|
|
17
|
+
# in-process Memory store.
|
|
18
|
+
module Artifacts
|
|
19
|
+
REQUIRED_METHODS = %i[store fetch].freeze
|
|
20
|
+
NAMED = { memory: :Memory, file: :File, s3: :S3, active_record: :ActiveRecordStore }.freeze
|
|
21
|
+
|
|
22
|
+
# The store a configuration names: a store itself (an object, or a
|
|
23
|
+
# class or module whose own methods store and fetch), a store class
|
|
24
|
+
# (built with the options), or one of :memory, :file, :s3 and
|
|
25
|
+
# :active_record. A store needs `store` and `fetch`; `stored_by?` lets
|
|
26
|
+
# runs read only their own refs, and `expired` and `delete` serve
|
|
27
|
+
# cleanup.
|
|
28
|
+
def self.resolve(store, **)
|
|
29
|
+
return if store.nil?
|
|
30
|
+
return store if REQUIRED_METHODS.all? { |method_name| store.respond_to?(method_name) }
|
|
31
|
+
return validate!(store.new(**)) if store.is_a?(Class)
|
|
32
|
+
return validate!(store) unless store.is_a?(Symbol) || store.is_a?(String)
|
|
33
|
+
|
|
34
|
+
named(store, **)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.named(store, **)
|
|
38
|
+
name = NAMED.fetch(store.to_sym) do
|
|
39
|
+
raise ArgumentError, "unknown artifact store #{store.inspect}; use :s3, :active_record, :file or :memory"
|
|
40
|
+
end
|
|
41
|
+
const_get(name).new(**)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.validate!(store)
|
|
45
|
+
missing = REQUIRED_METHODS.reject { |method_name| store.respond_to?(method_name) }
|
|
46
|
+
return store if missing.empty?
|
|
47
|
+
|
|
48
|
+
raise ArgumentError, "an artifact store must implement #{missing.join(", ")}"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Inside a step, the run's scoped view of the artifact store; elsewhere
|
|
53
|
+
# the store itself.
|
|
54
|
+
def self.artifacts
|
|
55
|
+
scoped_artifacts || artifact_backend
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The configured store itself, never a run's scoped view of it.
|
|
59
|
+
# Built once per configuration, under a lock, so threads that resolve it
|
|
60
|
+
# together share one store.
|
|
61
|
+
def self.artifact_backend
|
|
62
|
+
signature = artifact_signature
|
|
63
|
+
current = @_artifact_current
|
|
64
|
+
return current.last if current&.first == signature
|
|
65
|
+
|
|
66
|
+
(@_artifact_lock ||= Mutex.new).synchronize do
|
|
67
|
+
current = @_artifact_current
|
|
68
|
+
return current.last if current&.first == signature
|
|
69
|
+
|
|
70
|
+
@_artifact_current = [signature, build_artifact_backend].freeze
|
|
71
|
+
@_artifact_current.last
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.build_artifact_backend
|
|
76
|
+
resolved = Artifacts.resolve(config.artifact_store, **(config.artifact_options || {}))
|
|
77
|
+
resolved ||= Artifacts::Memory.new if config.test_mode
|
|
78
|
+
resolved || raise(ArtifactStoreMissing)
|
|
79
|
+
end
|
|
80
|
+
private_class_method :build_artifact_backend
|
|
81
|
+
|
|
82
|
+
# A configured store object is compared by identity; a name or class by
|
|
83
|
+
# value, with its options; test_mode matters only when nothing is set.
|
|
84
|
+
def self.artifact_signature
|
|
85
|
+
store = config.artifact_store
|
|
86
|
+
name = store.is_a?(Symbol) || store.is_a?(String) || store.is_a?(Module) ? snapshot_value(store) : store.object_id
|
|
87
|
+
[name, snapshot_value(config.artifact_options || {}), store.nil? && config.test_mode]
|
|
88
|
+
end
|
|
89
|
+
private_class_method :artifact_signature
|
|
90
|
+
|
|
91
|
+
def self.artifacts=(store)
|
|
92
|
+
config.artifact_store = store
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def self.scoped_artifacts
|
|
96
|
+
Thread.current[:smith_scoped_artifacts]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.scoped_artifacts=(store)
|
|
100
|
+
Thread.current[:smith_scoped_artifacts] = store
|
|
101
|
+
end
|
|
5
102
|
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module Batch
|
|
5
|
+
# Whether a succeeded result's message has the shape of an Anthropic
|
|
6
|
+
# Messages reply, as the provider's parser reads it: `content` an Array of
|
|
7
|
+
# blocks, each a Hash with a String `type` (a text block's `text` a
|
|
8
|
+
# String), and `usage` a Hash whose token counts are non-negative
|
|
9
|
+
# Integers. Anything else is refused before the step runs.
|
|
10
|
+
module MessageShape
|
|
11
|
+
COUNTS = %w[input_tokens output_tokens].freeze
|
|
12
|
+
OPTIONAL_COUNTS = %w[cache_read_input_tokens cache_creation_input_tokens].freeze
|
|
13
|
+
private_constant :COUNTS, :OPTIONAL_COUNTS
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def valid?(message)
|
|
18
|
+
message.is_a?(Hash) && message["content"].is_a?(Array) && message["content"].all? { block?(_1) } &&
|
|
19
|
+
usage?(message["usage"])
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def block?(block)
|
|
23
|
+
return false unless block.is_a?(Hash) && block["type"].is_a?(String)
|
|
24
|
+
|
|
25
|
+
block["type"] != "text" || block["text"].is_a?(String)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def usage?(usage)
|
|
29
|
+
usage.is_a?(Hash) && COUNTS.all? { count?(usage[_1]) } &&
|
|
30
|
+
OPTIONAL_COUNTS.all? { usage[_1].nil? || count?(usage[_1]) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def count?(value) = value.is_a?(Integer) && !value.negative?
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/smith/batch.rb
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
require_relative "diagnostic_text"
|
|
7
|
+
require_relative "batch/message_shape"
|
|
8
|
+
|
|
9
|
+
module Smith
|
|
10
|
+
# Batch mode (`execute Agent, mode: :batch`, and the same on `fan_out`): a
|
|
11
|
+
# step's provider request is sent through the host's batch queue instead of
|
|
12
|
+
# over the wire, and the step waits for its result. Smith renders exactly
|
|
13
|
+
# the request a synchronous call would post, hands it to
|
|
14
|
+
# `Smith.config.batch_queue.enqueue(custom_id:, await_token:, request:,
|
|
15
|
+
# execution_key:)` and waits on `:batch_result`; the host submits batches
|
|
16
|
+
# (Smith::Providers::Anthropic::Batches), collects their results, and
|
|
17
|
+
# resumes the run with every result of its wait. Smith never submits or
|
|
18
|
+
# polls, and waits for the results without sleeping (an in-process
|
|
19
|
+
# `retry_on` backoff sleeps as on any step; `durable: true` waits instead).
|
|
20
|
+
module Batch
|
|
21
|
+
EVENT = "batch_result"
|
|
22
|
+
RESULT_TYPES = %w[succeeded errored canceled expired].freeze
|
|
23
|
+
MISSING = "missing"
|
|
24
|
+
MAX_REQUEST_BYTES = 32 * 1024 * 1024
|
|
25
|
+
MAX_RESULTS_BYTES = 64 * 1024 * 1024
|
|
26
|
+
INVALID_REQUEST = "invalid_request_error"
|
|
27
|
+
CREDENTIAL_HEADERS = %w[authorization x-api-key x-goog-api-key].freeze
|
|
28
|
+
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
# The request a batch carries for one call: the provider, the payload it
|
|
32
|
+
# would post, and the call's own headers (never credentials: the
|
|
33
|
+
# connection's stay in it, and a call's own are refused), JSON-shaped,
|
|
34
|
+
# bounded and frozen.
|
|
35
|
+
def request(provider:, params:, headers:)
|
|
36
|
+
if headers.to_h.keys.any? { CREDENTIAL_HEADERS.include?(_1.to_s.downcase) }
|
|
37
|
+
raise WorkflowError, "a batch request cannot carry credentials in its headers"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
encoded = JSON.generate("provider" => provider.to_s, "params" => params, "headers" => headers.to_h)
|
|
41
|
+
if encoded.bytesize > MAX_REQUEST_BYTES
|
|
42
|
+
raise WorkflowError, "a batch request is #{encoded.bytesize} bytes, past the #{MAX_REQUEST_BYTES} Smith sends"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
deep_freeze(JSON.parse(encoded))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# What identifies a request's content: a SHA-256 of its payload and
|
|
49
|
+
# headers as JSON gives them, so the request a delivery answers can be
|
|
50
|
+
# told from one the step would render now.
|
|
51
|
+
def digest(request) = digest_of(request.fetch("params"), request.fetch("headers"))
|
|
52
|
+
|
|
53
|
+
# Whether a call renders a request other than the one sent (`digest`).
|
|
54
|
+
# Nothing is sent, so nothing in it is refused; one JSON cannot give
|
|
55
|
+
# counts as changed.
|
|
56
|
+
def changed?(digest, params, headers)
|
|
57
|
+
digest_of(params, headers.to_h) != digest
|
|
58
|
+
rescue JSON::GeneratorError, JSON::NestingError, Encoding::UndefinedConversionError
|
|
59
|
+
true
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def digest_of(params, headers) = Digest::SHA256.hexdigest(JSON.generate([params, headers]))
|
|
63
|
+
|
|
64
|
+
# A step's request id: its wait token, and for a fan-out branch a key of
|
|
65
|
+
# the token and the branch (64 hex characters, a valid custom_id).
|
|
66
|
+
def custom_id(token, branch_key = nil)
|
|
67
|
+
branch_key.nil? ? token : Digest::SHA256.hexdigest("#{token}/#{branch_key}")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The delivered results, checked: one per expected id, each a result of
|
|
71
|
+
# a known type, keys as JSON gives them. Raises ArgumentError otherwise.
|
|
72
|
+
def results!(payload, expected_ids)
|
|
73
|
+
results = stringified(payload)
|
|
74
|
+
unless results.keys.sort == expected_ids.sort
|
|
75
|
+
raise ArgumentError, "a batch delivery must hold exactly the results of its #{expected_ids.length} requests"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
results.each_value { |result| check_result!(result) }
|
|
79
|
+
deep_freeze(results)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The message of a succeeded result, or the error a failed one raises:
|
|
83
|
+
# BatchRequestInvalid for a request the provider refused, else
|
|
84
|
+
# BatchResultFailed (retryable).
|
|
85
|
+
def message!(result)
|
|
86
|
+
case result.fetch("type")
|
|
87
|
+
when "succeeded" then result.fetch("message")
|
|
88
|
+
when "errored" then raise_errored!(result)
|
|
89
|
+
else raise BatchResultFailed.new(kind: result.fetch("type"))
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def raise_errored!(result)
|
|
94
|
+
error = result["error"]
|
|
95
|
+
type = error.dig("error", "type") || error["type"] if error.is_a?(Hash)
|
|
96
|
+
raise BatchRequestInvalid if type == INVALID_REQUEST
|
|
97
|
+
|
|
98
|
+
raise BatchResultFailed.new(kind: "errored", error_type: type.is_a?(String) ? type : nil)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def check_result!(result)
|
|
102
|
+
raise ArgumentError, "a batch result must be a Hash with a type" unless result.is_a?(Hash)
|
|
103
|
+
|
|
104
|
+
type = result["type"]
|
|
105
|
+
raise ArgumentError, "a batch result's type must be one of #{RESULT_TYPES.join(", ")}" unless
|
|
106
|
+
RESULT_TYPES.include?(type) || type == MISSING
|
|
107
|
+
return unless type == "succeeded" && !MessageShape.valid?(result["message"])
|
|
108
|
+
|
|
109
|
+
raise ArgumentError, "a succeeded batch result must carry a message of the provider's reply shape"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def stringified(payload)
|
|
113
|
+
raise ArgumentError, "a batch delivery must be a Hash of request ids to results" unless payload.is_a?(Hash)
|
|
114
|
+
|
|
115
|
+
encoded = JSON.generate(payload)
|
|
116
|
+
raise ArgumentError, "a batch delivery is past #{MAX_RESULTS_BYTES} bytes" if encoded.bytesize > MAX_RESULTS_BYTES
|
|
117
|
+
|
|
118
|
+
JSON.parse(encoded)
|
|
119
|
+
rescue JSON::GeneratorError, JSON::NestingError, Encoding::UndefinedConversionError
|
|
120
|
+
raise ArgumentError, "a batch delivery must be JSON-shaped"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def deep_freeze(value)
|
|
124
|
+
case value
|
|
125
|
+
when Hash then value.each_value { deep_freeze(_1) }.freeze
|
|
126
|
+
when Array then value.each { deep_freeze(_1) }.freeze
|
|
127
|
+
else value.freeze
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# The results a resumed step takes, one per branch (nil for a step
|
|
132
|
+
# without branches), each with the digest of the request it answers, and
|
|
133
|
+
# what a result does when its call renders another request
|
|
134
|
+
# (`on_change`). Each is taken once: a follow-up call the step makes
|
|
135
|
+
# after it (a repair) goes over the wire. `untaken` is what no call took
|
|
136
|
+
# (a branch cancelled by a sibling's failure) or set aside (a result its
|
|
137
|
+
# call would not use), paid for all the same.
|
|
138
|
+
class Delivery
|
|
139
|
+
Entry = Struct.new(:result, :digest)
|
|
140
|
+
|
|
141
|
+
attr_reader :on_change
|
|
142
|
+
|
|
143
|
+
def initialize(entries_by_branch, on_change: :keep)
|
|
144
|
+
@entries = entries_by_branch.dup
|
|
145
|
+
@set_aside = {}
|
|
146
|
+
@on_change = on_change
|
|
147
|
+
@lock = Mutex.new
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def take(branch_key) = @lock.synchronize { @entries.delete(branch_key) }
|
|
151
|
+
|
|
152
|
+
def for?(branch_key) = @lock.synchronize { @entries.key?(branch_key) }
|
|
153
|
+
|
|
154
|
+
def set_aside(branch_key, entry) = @lock.synchronize { @set_aside[branch_key] = entry }
|
|
155
|
+
|
|
156
|
+
def untaken
|
|
157
|
+
@lock.synchronize do
|
|
158
|
+
@set_aside.merge(@entries).tap do
|
|
159
|
+
@entries.clear
|
|
160
|
+
@set_aside.clear
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module Budget
|
|
5
|
+
# What a provider request may spend (the hold a host's budget ledger is
|
|
6
|
+
# asked to admit) or did spend (what it is reconciled with): its input
|
|
7
|
+
# tokens (uncached, cache reads and cache writes together), its output
|
|
8
|
+
# tokens (thinking included), and its cost from the pricing catalog, nil
|
|
9
|
+
# when the model has no price there.
|
|
10
|
+
Amount = Data.define(:input_tokens, :output_tokens, :cost) do
|
|
11
|
+
def initialize(input_tokens:, output_tokens:, cost:)
|
|
12
|
+
unless [input_tokens, output_tokens].all? { _1.is_a?(Integer) && !_1.negative? }
|
|
13
|
+
raise ArgumentError, "a budget amount's token counts must be non-negative Integers"
|
|
14
|
+
end
|
|
15
|
+
unless cost.nil? || (cost.is_a?(Numeric) && cost.finite? && !cost.negative?)
|
|
16
|
+
raise ArgumentError, "a budget amount's cost must be nil or a non-negative number"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
super
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def tokens = input_tokens + output_tokens
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "bigdecimal"
|
|
4
4
|
require "dry-initializer"
|
|
5
|
+
require_relative "decimal_context"
|
|
5
6
|
|
|
6
7
|
module Smith
|
|
7
8
|
module Budget
|
|
@@ -26,6 +27,25 @@ module Smith
|
|
|
26
27
|
finite_float!(amount)
|
|
27
28
|
end
|
|
28
29
|
|
|
30
|
+
# Remaining capacity, or one of parts equal shares of it, is reserved as
|
|
31
|
+
# read, so parts reservations of the external value must never exceed the
|
|
32
|
+
# exact amount. An Integer keeps floor division; a Float steps down until
|
|
33
|
+
# its decimal form fits, which takes O(1) steps because Float division
|
|
34
|
+
# lands within one ulp of the exact share.
|
|
35
|
+
def externalize_remaining(key, amount, parts = 1)
|
|
36
|
+
unless parts.is_a?(Integer) && parts.positive?
|
|
37
|
+
raise ArgumentError, "budget share parts must be a positive Integer"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
share = externalize_amount(key, amount) / parts
|
|
41
|
+
return share unless share.is_a?(Float)
|
|
42
|
+
|
|
43
|
+
DecimalContext.call do
|
|
44
|
+
share = share.prev_float while internalize_amount(share) * parts > amount
|
|
45
|
+
end
|
|
46
|
+
share
|
|
47
|
+
end
|
|
48
|
+
|
|
29
49
|
private
|
|
30
50
|
|
|
31
51
|
def internalize_amount(amount)
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
module Budget
|
|
7
|
+
# The rest of a tool loop that paused for budget: the messages a step's
|
|
8
|
+
# call added before a host's budget ledger refused a later round (the
|
|
9
|
+
# model's tool calls, their results, its thinking and its signatures),
|
|
10
|
+
# kept with the step's retry so that the retry continues the loop from
|
|
11
|
+
# them instead of running its tools again. JSON-shaped and at most
|
|
12
|
+
# MAX_BYTES; a loop whose messages cannot be kept so (an attachment in
|
|
13
|
+
# them, or too many bytes) does not pause. A response memo
|
|
14
|
+
# (Smith::ResponseMemo) keeps a call's messages the same way, with each
|
|
15
|
+
# response's `usage`: its token counts, stop reason and message id.
|
|
16
|
+
module Continuation
|
|
17
|
+
MAX_BYTES = 1024 * 1024
|
|
18
|
+
ROLES = %w[assistant tool user].freeze
|
|
19
|
+
TOKENS = %w[input_tokens output_tokens cached_tokens cache_creation_tokens thinking_tokens].freeze
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# `kept` (an earlier pause's) followed by `messages`, or nil; with
|
|
24
|
+
# `usage`, each response's usage too (its facts from `facts`, by
|
|
25
|
+
# message, for a streamed response, whose facts its stream gave).
|
|
26
|
+
def dump(kept, messages, usage: false, facts: {})
|
|
27
|
+
dumped = messages.map { message(_1, usage: (facts[_1] || {} if usage)) || (return nil) }
|
|
28
|
+
list = JSON.parse(JSON.generate([*kept, *dumped]))
|
|
29
|
+
list if JSON.generate(list).bytesize <= MAX_BYTES
|
|
30
|
+
rescue JSON::GeneratorError, JSON::NestingError, Encoding::UndefinedConversionError
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def message(message, usage: nil)
|
|
35
|
+
content = content_of(message.content)
|
|
36
|
+
return if content == :unkept
|
|
37
|
+
|
|
38
|
+
{ "role" => message.role.to_s, "content" => content, "tool_call_id" => message.tool_call_id,
|
|
39
|
+
"model_id" => message.model_id, "tool_calls" => message.tool_calls&.values&.map { tool_call(_1) },
|
|
40
|
+
"thinking" => thinking_of(message.thinking), "usage" => (usage_of(message, usage) if usage) }.compact
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def usage_of(message, streamed)
|
|
44
|
+
tokens = message.respond_to?(:tokens) ? message.tokens&.to_h : nil
|
|
45
|
+
facts = Providers::ResponseFacts.from_message(message).merge(streamed)
|
|
46
|
+
usage = { "tokens" => tokens&.transform_keys(&:to_s), "stop_reason" => facts[:stop_reason],
|
|
47
|
+
"id" => facts[:provider_message_id] }.compact
|
|
48
|
+
usage unless usage.empty?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def thinking_of(thinking) = thinking && { "text" => thinking.text, "signature" => thinking.signature }.compact
|
|
52
|
+
|
|
53
|
+
def content_of(content)
|
|
54
|
+
case content
|
|
55
|
+
when nil, String then content
|
|
56
|
+
when RubyLLM::Content::Raw then { "raw" => content.value }
|
|
57
|
+
# A structured reply, as RubyLLM parses one under a schema.
|
|
58
|
+
when Hash, Array then { "json" => content }
|
|
59
|
+
else :unkept
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def tool_call(call)
|
|
64
|
+
{ "id" => call.id, "name" => call.name, "arguments" => call.arguments,
|
|
65
|
+
"thought_signature" => call.thought_signature }.compact
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The messages a kept continuation holds, for a chat; ArgumentError if
|
|
69
|
+
# it is not one `dump` makes.
|
|
70
|
+
def load(list)
|
|
71
|
+
raise ArgumentError, "a budget continuation must be a list of messages" unless valid?(list)
|
|
72
|
+
|
|
73
|
+
list.map { message_from(_1) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def message_from(entry)
|
|
77
|
+
content = entry["content"]
|
|
78
|
+
message = RubyLLM::Message.new(
|
|
79
|
+
role: entry["role"].to_sym, content: content(content), tool_call_id: entry["tool_call_id"],
|
|
80
|
+
model_id: entry["model_id"], tool_calls: tool_calls(entry["tool_calls"]),
|
|
81
|
+
thinking: thinking(entry["thinking"]), **kept_usage(entry["usage"])
|
|
82
|
+
)
|
|
83
|
+
# RubyLLM's constructor reads a Hash as attachments.
|
|
84
|
+
message.content = content["json"] if structured?(content)
|
|
85
|
+
message
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# A kept response's token counts, and its facts as a raw body
|
|
89
|
+
# Providers::ResponseFacts reads.
|
|
90
|
+
def kept_usage(usage)
|
|
91
|
+
return {} unless usage
|
|
92
|
+
|
|
93
|
+
{ **usage.fetch("tokens", {}).transform_keys(&:to_sym),
|
|
94
|
+
raw: Providers::ResponseFacts.kept(stop_reason: usage["stop_reason"], id: usage["id"]) }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def valid?(list)
|
|
98
|
+
list.is_a?(Array) && !list.empty? && JSON.generate(list).bytesize <= MAX_BYTES && list.all? { entry?(_1) }
|
|
99
|
+
rescue JSON::GeneratorError, JSON::NestingError
|
|
100
|
+
false
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def entry?(entry)
|
|
104
|
+
entry.is_a?(Hash) && ROLES.include?(entry["role"]) && content?(entry["content"]) &&
|
|
105
|
+
entry.values_at("tool_call_id", "model_id").all? { optional_string?(_1) } &&
|
|
106
|
+
calls?(entry["tool_calls"]) && thinking?(entry["thinking"]) && usage?(entry["usage"])
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def usage?(usage)
|
|
110
|
+
return true if usage.nil?
|
|
111
|
+
return false unless usage.is_a?(Hash) && (usage.keys - %w[tokens stop_reason id]).empty?
|
|
112
|
+
|
|
113
|
+
tokens?(usage.fetch("tokens", {})) && optional_string?(usage["stop_reason"]) && optional_string?(usage["id"])
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def tokens?(tokens)
|
|
117
|
+
tokens.is_a?(Hash) && (tokens.keys - TOKENS).empty? && tokens.values.all? { _1.is_a?(Integer) && !_1.negative? }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def calls?(calls) = calls.nil? || (calls.is_a?(Array) && calls.all? { call?(_1) })
|
|
121
|
+
|
|
122
|
+
def thinking?(thinking) = thinking.nil? || (thinking.is_a?(Hash) && thinking.values.all?(String))
|
|
123
|
+
|
|
124
|
+
def content?(content)
|
|
125
|
+
content.nil? || content.is_a?(String) || (content.is_a?(Hash) && [["raw"], ["json"]].include?(content.keys))
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def structured?(content) = content.is_a?(Hash) && content.key?("json")
|
|
129
|
+
|
|
130
|
+
def call?(call)
|
|
131
|
+
call.is_a?(Hash) && call["id"].is_a?(String) && call["name"].is_a?(String) && call["arguments"].is_a?(Hash) &&
|
|
132
|
+
optional_string?(call["thought_signature"])
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def optional_string?(value) = value.nil? || value.is_a?(String)
|
|
136
|
+
|
|
137
|
+
def content(value)
|
|
138
|
+
return value unless value.is_a?(Hash)
|
|
139
|
+
|
|
140
|
+
structured?(value) ? nil : RubyLLM::Content::Raw.new(value.fetch("raw"))
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def tool_calls(calls)
|
|
144
|
+
calls&.to_h do |call|
|
|
145
|
+
[call["id"], RubyLLM::ToolCall.new(id: call["id"], name: call["name"], arguments: call["arguments"],
|
|
146
|
+
thought_signature: call["thought_signature"])]
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def thinking(value) = value && RubyLLM::Thinking.new(text: value["text"], signature: value["signature"])
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|