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,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "amount"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
module Budget
|
|
7
|
+
# The protocol of a host's budget ledger (a workflow's `budget_ledger`),
|
|
8
|
+
# and how Smith calls it.
|
|
9
|
+
#
|
|
10
|
+
# `reserve!(amount, key:, expires_at:)` admits a provider request's hold
|
|
11
|
+
# (a Budget::Amount) when it returns true, and refuses it otherwise.
|
|
12
|
+
# `reconcile!(key, amount)` records what the request spent, in place of
|
|
13
|
+
# its hold if it had one: a key may be reconciled that was never held
|
|
14
|
+
# (a later round of a tool loop, or a result that came for a hold that
|
|
15
|
+
# expired). `release!(key)` says it spent nothing. A hold not settled by
|
|
16
|
+
# `expires_at` (its process died during the request) counts for nothing
|
|
17
|
+
# after it; Smith runs no timer. A key may come again after a crash: a
|
|
18
|
+
# reserve of it replaces its hold, a release ends its hold, and its
|
|
19
|
+
# reconcile is final (a ledger records it once). Fan-out branches call
|
|
20
|
+
# the ledger from their own threads.
|
|
21
|
+
#
|
|
22
|
+
# A refusal raises BudgetUnavailable before the request is sent. An
|
|
23
|
+
# error from building the ledger or from `reserve!` becomes
|
|
24
|
+
# BudgetLedgerError (the hold, if `reserve!` took it, is released), as
|
|
25
|
+
# nothing was sent. An error from `reconcile!` or `release!` is logged
|
|
26
|
+
# by class and the step goes on: the money is spent, and a ledger that
|
|
27
|
+
# could not record it must not cost the answer.
|
|
28
|
+
module HostLedger
|
|
29
|
+
METHODS = %i[reserve! reconcile! release!].freeze
|
|
30
|
+
INHERITED = :smith_inherited_budget_ledger
|
|
31
|
+
SCOPE_STRING_BYTES = 256
|
|
32
|
+
private_constant :INHERITED, :SCOPE_STRING_BYTES
|
|
33
|
+
|
|
34
|
+
module_function
|
|
35
|
+
|
|
36
|
+
def check!(ledger)
|
|
37
|
+
return ledger if ledger.nil? || METHODS.all? { ledger.respond_to?(_1) }
|
|
38
|
+
|
|
39
|
+
raise WorkflowError, "a budget_ledger must respond to #{METHODS.join(", ")}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The run's ledger from its builder and the scope it was created with.
|
|
43
|
+
def build(builder, scope)
|
|
44
|
+
check!(builder.call(scope))
|
|
45
|
+
rescue WorkflowError
|
|
46
|
+
raise
|
|
47
|
+
rescue StandardError => e
|
|
48
|
+
raise BudgetLedgerError, "the budget ledger could not be built (#{e.class})"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Admits the hold until `seconds` (Smith.config.budget_hold_ttl, plus a
|
|
52
|
+
# batch's wait) from now.
|
|
53
|
+
def admit!(ledger, amount, key, seconds: Smith.config.budget_hold_ttl)
|
|
54
|
+
raise BudgetUnavailable unless ledger.reserve!(amount, key:, expires_at: Time.now.utc + seconds) == true
|
|
55
|
+
rescue BudgetUnavailable
|
|
56
|
+
raise
|
|
57
|
+
rescue StandardError => e
|
|
58
|
+
settle(ledger, key, nil)
|
|
59
|
+
raise BudgetLedgerError, "the budget ledger could not reserve (#{e.class})"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def settle(ledger, key, amount)
|
|
63
|
+
amount ? ledger.reconcile!(key, amount) : ledger.release!(key)
|
|
64
|
+
rescue StandardError => e
|
|
65
|
+
Smith.config.logger&.error("Smith budget ledger #{amount ? "reconcile!" : "release!"} error: #{e.class}")
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# What a response's usage cost, as an Amount, priced as Smith prices
|
|
70
|
+
# usage; a count that is not a non-negative Integer counts as 0.
|
|
71
|
+
def amount(usage, model:, provider:, mode: :sync)
|
|
72
|
+
counts = [usage.input_tokens, usage.output_tokens, usage.cache_read_tokens, usage.cache_write_tokens]
|
|
73
|
+
input, output, reads, writes = counts.map { _1.is_a?(Integer) && _1.positive? ? _1 : 0 }
|
|
74
|
+
Amount.new(
|
|
75
|
+
input_tokens: input + reads + writes, output_tokens: output,
|
|
76
|
+
cost: Pricing.compute_cost(model:, provider:, input_tokens: input, output_tokens: output,
|
|
77
|
+
cache_read_tokens: reads, cache_write_tokens: writes, mode:)
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The scope a run's ledger is built from: the values its `keys` have in
|
|
82
|
+
# the run's trusted context when it is created, JSON scalars (a String
|
|
83
|
+
# of at most SCOPE_STRING_BYTES bytes), kept with the run so that what
|
|
84
|
+
# a step writes later never chooses another ledger.
|
|
85
|
+
def scope!(keys, context, untrusted)
|
|
86
|
+
keys.to_h do |key|
|
|
87
|
+
raise WorkflowError, "budget_ledger scope key #{key.inspect} is untrusted" if untrusted.include?(key)
|
|
88
|
+
|
|
89
|
+
[key, scope_value!(key, context.is_a?(Hash) ? context[key] : nil)]
|
|
90
|
+
end.freeze
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# A stored scope, if it names the declared keys; nil (capture it
|
|
94
|
+
# again) if the declaration changed.
|
|
95
|
+
def stored_scope(keys, stored)
|
|
96
|
+
return unless stored.is_a?(Hash) && same_keys?(keys, stored)
|
|
97
|
+
|
|
98
|
+
stored = stored.transform_keys(&:to_s)
|
|
99
|
+
keys.to_h { |key| [key, scope_value!(key, stored[key.to_s])] }.freeze
|
|
100
|
+
rescue WorkflowError
|
|
101
|
+
raise SerializationError, "persisted workflow budget_ledger_scope is invalid"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def same_keys?(keys, stored) = stored.keys.map(&:to_s).sort == keys.map(&:to_s).sort
|
|
105
|
+
|
|
106
|
+
def scope_value!(key, value)
|
|
107
|
+
case value
|
|
108
|
+
when nil, true, false, Integer then value
|
|
109
|
+
when Float then value.finite? ? value : invalid_scope!(key)
|
|
110
|
+
when String then value.bytesize <= SCOPE_STRING_BYTES ? value.dup.freeze : invalid_scope!(key)
|
|
111
|
+
else invalid_scope!(key)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def invalid_scope!(key)
|
|
116
|
+
raise WorkflowError, "budget_ledger scope key #{key.inspect} must hold a JSON scalar " \
|
|
117
|
+
"(a String of at most #{SCOPE_STRING_BYTES} bytes)"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# A nested workflow built in the block charges `source` (the enclosing
|
|
121
|
+
# run's ledger, as a callable) unless it names a ledger of its own.
|
|
122
|
+
def inheriting(source)
|
|
123
|
+
outer = Thread.current[INHERITED]
|
|
124
|
+
Thread.current[INHERITED] = source
|
|
125
|
+
yield
|
|
126
|
+
ensure
|
|
127
|
+
Thread.current[INHERITED] = outer
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def take_inherited
|
|
131
|
+
source = Thread.current[INHERITED]
|
|
132
|
+
Thread.current[INHERITED] = nil
|
|
133
|
+
source
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
data/lib/smith/budget/ledger.rb
CHANGED
|
@@ -58,12 +58,16 @@ module Smith
|
|
|
58
58
|
@mutex.synchronize { release_reservation(token) }
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
-
def remaining(key)
|
|
61
|
+
def remaining(key) = remaining_share(key, 1)
|
|
62
|
+
|
|
63
|
+
# One of parts equal shares of the remaining amount, taken from the exact
|
|
64
|
+
# internal value so that parts reservations of it always fit.
|
|
65
|
+
def remaining_share(key, parts)
|
|
62
66
|
dimension = @amount_contract.dimension!(key)
|
|
63
67
|
@amount_contract.known_dimension!(dimension)
|
|
64
68
|
@mutex.synchronize do
|
|
65
69
|
amount = @state_transition.remaining(@publication.state, dimension)
|
|
66
|
-
@representation.
|
|
70
|
+
@representation.externalize_remaining(dimension, amount, parts)
|
|
67
71
|
end
|
|
68
72
|
end
|
|
69
73
|
|
|
@@ -96,14 +100,14 @@ module Smith
|
|
|
96
100
|
@representation.internalize(external)
|
|
97
101
|
end
|
|
98
102
|
|
|
99
|
-
def create_reservation(amounts)
|
|
103
|
+
def create_reservation(amounts, checked: true)
|
|
100
104
|
token = Object.new.freeze
|
|
101
105
|
receipt = Reservation.new(
|
|
102
106
|
ledger_identity: @identity,
|
|
103
107
|
token:,
|
|
104
108
|
amounts: @representation.externalize(amounts)
|
|
105
109
|
)
|
|
106
|
-
next_state = @state_transition.reserve(@publication.state, amounts)
|
|
110
|
+
next_state = @state_transition.reserve(@publication.state, amounts, checked:)
|
|
107
111
|
@publication.add(@public_state_validator.call(next_state), token:, amounts:)
|
|
108
112
|
receipt
|
|
109
113
|
end
|
|
@@ -132,3 +136,5 @@ module Smith
|
|
|
132
136
|
end
|
|
133
137
|
end
|
|
134
138
|
end
|
|
139
|
+
|
|
140
|
+
require_relative "spent_reservation"
|
|
@@ -11,12 +11,9 @@ module Smith
|
|
|
11
11
|
|
|
12
12
|
option :limits
|
|
13
13
|
|
|
14
|
-
def reserve(state, amounts)
|
|
14
|
+
def reserve(state, amounts, checked: true)
|
|
15
15
|
DecimalContext.call do
|
|
16
|
-
amounts.each
|
|
17
|
-
requested = state.consumed[key] + state.reserved[key] + amount
|
|
18
|
-
raise BudgetExceeded unless requested.finite? && requested <= limits.fetch(key)
|
|
19
|
-
end
|
|
16
|
+
amounts.each { |key, amount| admit!(state, key, amount, checked) }
|
|
20
17
|
|
|
21
18
|
LedgerState.new(consumed: state.consumed, reserved: add(state.reserved, amounts))
|
|
22
19
|
end
|
|
@@ -49,6 +46,11 @@ module Smith
|
|
|
49
46
|
|
|
50
47
|
private
|
|
51
48
|
|
|
49
|
+
def admit!(state, key, amount, checked)
|
|
50
|
+
requested = state.consumed[key] + state.reserved[key] + amount
|
|
51
|
+
raise BudgetExceeded unless requested.finite? && (!checked || requested <= limits.fetch(key))
|
|
52
|
+
end
|
|
53
|
+
|
|
52
54
|
def add(current, amounts)
|
|
53
55
|
merge(current, amounts) { |value, amount| value + amount }
|
|
54
56
|
end
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "amount"
|
|
6
|
+
|
|
7
|
+
module Smith
|
|
8
|
+
module Budget
|
|
9
|
+
# The hold a provider request asks a host's budget ledger for before it
|
|
10
|
+
# is sent: its output cap and an estimate of its input, priced from the
|
|
11
|
+
# pricing catalog.
|
|
12
|
+
#
|
|
13
|
+
# The output cap is the request's own (`max_tokens`, or its provider's
|
|
14
|
+
# name for it, as Providers::OutputLimit reads it), else the model's
|
|
15
|
+
# maximum, else for Anthropic DEFAULT_OUTPUT_CAP (what RubyLLM asks for
|
|
16
|
+
# when it knows no more), else the model's context window, else
|
|
17
|
+
# DEFAULT_OUTPUT_CAP; Smith.config.budget_hold_output_tokens, when set,
|
|
18
|
+
# caps it. The input estimate counts text (messages, tool definitions
|
|
19
|
+
# and calls, the output schema) at one token per BYTES_PER_TOKEN bytes,
|
|
20
|
+
# an image at IMAGE_TOKENS, a PDF at PAGE_TOKENS a page (its pages
|
|
21
|
+
# counted in it, else one per BYTES_PER_PAGE), another attachment's
|
|
22
|
+
# bytes as text, and each message's framing at MESSAGE_TOKENS, and is
|
|
23
|
+
# capped at the model's context window. Images and documents are
|
|
24
|
+
# recognized as RubyLLM attachments and in raw content blocks (base64
|
|
25
|
+
# `data` beside its media type, or a data URL). It is an estimate, not a
|
|
26
|
+
# bound: what the request spent replaces it.
|
|
27
|
+
module RequestHold
|
|
28
|
+
BYTES_PER_TOKEN = 3
|
|
29
|
+
IMAGE_TOKENS = 1_600
|
|
30
|
+
PAGE_TOKENS = 2_000
|
|
31
|
+
BYTES_PER_PAGE = 50_000
|
|
32
|
+
MESSAGE_TOKENS = 8
|
|
33
|
+
DEFAULT_OUTPUT_CAP = 4_096
|
|
34
|
+
OUTPUT_CAPS = Providers::OutputLimit::PARAM_PATHS.values.flatten(1).uniq.freeze
|
|
35
|
+
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
# The hold for a request RubyLLM's Provider#complete is about to make,
|
|
39
|
+
# from its `messages:`, `tools:`, `schema:` and `params:`.
|
|
40
|
+
def for_request(provider:, model:, mode: :sync, **request)
|
|
41
|
+
context_window = model.context_window if model.respond_to?(:context_window)
|
|
42
|
+
input = [input_estimate(*request.values_at(:messages, :tools, :schema)), context_window].compact.min
|
|
43
|
+
output = [output_cap(provider, request[:params], model), Smith.config.budget_hold_output_tokens].compact.min
|
|
44
|
+
Amount.new(input_tokens: input, output_tokens: output,
|
|
45
|
+
cost: Pricing.compute_cost(model: model.id, provider: provider.to_s, input_tokens: input,
|
|
46
|
+
output_tokens: output, mode:))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def input_estimate(messages, tools, schema)
|
|
50
|
+
counter = Counter.new
|
|
51
|
+
Array(messages).each { counter.message(_1) }
|
|
52
|
+
tools.to_h.each_value { counter.tool(_1) }
|
|
53
|
+
counter.text(schema) if schema
|
|
54
|
+
counter.tokens
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
PAGE = %r{/Type\s*/Page(?![a-zA-Z])}n
|
|
58
|
+
PAGES = ObjectSpace::WeakMap.new
|
|
59
|
+
PAGES_LOCK = Mutex.new
|
|
60
|
+
private_constant :PAGE, :PAGES, :PAGES_LOCK
|
|
61
|
+
|
|
62
|
+
# A PDF's pages (its bytes from the block), counted in it once per
|
|
63
|
+
# String, else one per BYTES_PER_PAGE.
|
|
64
|
+
def pages(source)
|
|
65
|
+
PAGES_LOCK.synchronize do
|
|
66
|
+
PAGES[source] ||= begin
|
|
67
|
+
bytes = yield(source).b
|
|
68
|
+
counted = bytes.scan(PAGE).length
|
|
69
|
+
counted.positive? ? counted : [(bytes.bytesize + BYTES_PER_PAGE - 1) / BYTES_PER_PAGE, 1].max
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def output_cap(provider, params, model)
|
|
75
|
+
paths = Providers::OutputLimit::PARAM_PATHS.fetch(provider.to_s, OUTPUT_CAPS)
|
|
76
|
+
[paths.lazy.filter_map { Providers::OutputLimit.lookup(params.to_h, _1) }.first,
|
|
77
|
+
(model.max_tokens if model.respond_to?(:max_tokens)),
|
|
78
|
+
(DEFAULT_OUTPUT_CAP if provider.to_s == "anthropic"),
|
|
79
|
+
(model.context_window if model.respond_to?(:context_window))]
|
|
80
|
+
.find { _1.is_a?(Integer) && _1.positive? } || DEFAULT_OUTPUT_CAP
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Counts what a request carries: bytes of text, images, document pages
|
|
84
|
+
# and messages.
|
|
85
|
+
class Counter
|
|
86
|
+
IMAGE_BLOCKS = %w[image input_image image_url].freeze
|
|
87
|
+
DOCUMENT_BLOCKS = %w[document file input_file].freeze
|
|
88
|
+
SOURCE_KEYS = %i[source inline_data inlineData].freeze
|
|
89
|
+
MEDIA_KEYS = %i[media_type mime_type mimeType].freeze
|
|
90
|
+
DATA_URL = %r{\Adata:([\w.+-]+/[\w.+-]+);base64,}
|
|
91
|
+
|
|
92
|
+
def initialize
|
|
93
|
+
@bytes = 0
|
|
94
|
+
@images = 0
|
|
95
|
+
@pages = 0
|
|
96
|
+
@messages = 0
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def tokens
|
|
100
|
+
((@bytes + BYTES_PER_TOKEN - 1) / BYTES_PER_TOKEN) + (@images * IMAGE_TOKENS) + (@pages * PAGE_TOKENS) +
|
|
101
|
+
(@messages * MESSAGE_TOKENS)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def message(message)
|
|
105
|
+
@messages += 1
|
|
106
|
+
content(message.content)
|
|
107
|
+
Array(message.tool_calls&.values).each { text(_1.to_h) } if message.respond_to?(:tool_calls)
|
|
108
|
+
thinking = message.thinking if message.respond_to?(:thinking)
|
|
109
|
+
text(thinking.text) if thinking.respond_to?(:text)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def tool(tool)
|
|
113
|
+
text(tool.name)
|
|
114
|
+
text(tool.description)
|
|
115
|
+
text(tool.params_schema) if tool.respond_to?(:params_schema)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def content(content)
|
|
119
|
+
case content
|
|
120
|
+
when RubyLLM::Content::Raw then raw(content.value)
|
|
121
|
+
when RubyLLM::Content
|
|
122
|
+
text(content.text)
|
|
123
|
+
content.attachments.each { attachment(_1) }
|
|
124
|
+
else raw(content)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def attachment(attachment)
|
|
129
|
+
return @images += 1 if attachment.image?
|
|
130
|
+
# A URL is not fetched to count it: one page.
|
|
131
|
+
return @pages += 1 if attachment.url?
|
|
132
|
+
return @pages += RequestHold.pages(attachment.content.to_s) { _1 } if attachment.pdf?
|
|
133
|
+
|
|
134
|
+
@bytes += attachment.content.to_s.bytesize
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Raw content: an image block, a block of base64 data or a data URL
|
|
138
|
+
# counts as what it carries; anything else as its text.
|
|
139
|
+
def raw(value)
|
|
140
|
+
case value
|
|
141
|
+
when Hash then block(value)
|
|
142
|
+
when Array then value.each { raw(_1) }
|
|
143
|
+
when String then string(value)
|
|
144
|
+
else text(value)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def block(hash)
|
|
149
|
+
kind = fetch(hash, :type).to_s
|
|
150
|
+
return @images += 1 if IMAGE_BLOCKS.include?(kind)
|
|
151
|
+
|
|
152
|
+
data, media = base64_data(hash)
|
|
153
|
+
return base64(data, media) if data
|
|
154
|
+
return @pages += 1 if DOCUMENT_BLOCKS.include?(kind) && !text_source?(hash)
|
|
155
|
+
|
|
156
|
+
hash.each do |key, value|
|
|
157
|
+
text(key)
|
|
158
|
+
raw(value)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Anthropic's `source` (base64), Gemini's `inline_data`, or the
|
|
163
|
+
# block's own `data` beside a media type.
|
|
164
|
+
def base64_data(hash)
|
|
165
|
+
source = first(hash, SOURCE_KEYS) || hash
|
|
166
|
+
return unless source.is_a?(Hash) && fetch(source, :type) != "text"
|
|
167
|
+
|
|
168
|
+
data = fetch(source, :data)
|
|
169
|
+
media = first(source, MEDIA_KEYS)
|
|
170
|
+
[data, media] if data.is_a?(String) && media.is_a?(String)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def first(hash, keys) = keys.lazy.filter_map { fetch(hash, _1) }.first
|
|
174
|
+
|
|
175
|
+
def text_source?(hash)
|
|
176
|
+
source = fetch(hash, :source)
|
|
177
|
+
source.is_a?(Hash) && %w[text content].include?(fetch(source, :type).to_s)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def string(value)
|
|
181
|
+
url = DATA_URL.match(value)
|
|
182
|
+
url ? base64(value, url[1], from: url[0].bytesize) : text(value)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Base64 data (from byte `from` of `encoded`): an image, a PDF's
|
|
186
|
+
# pages, or its decoded size as text. Only a PDF is decoded.
|
|
187
|
+
def base64(encoded, media, from: 0)
|
|
188
|
+
return @images += 1 if media.start_with?("image/")
|
|
189
|
+
if media == "application/pdf"
|
|
190
|
+
return @pages += RequestHold.pages(encoded) { _1.byteslice(from..).unpack1("m") }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
@bytes += (encoded.bytesize - from) * 3 / 4
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def fetch(hash, key) = hash.key?(key) ? hash[key] : hash[key.to_s]
|
|
197
|
+
|
|
198
|
+
def text(value)
|
|
199
|
+
case value
|
|
200
|
+
when nil then nil
|
|
201
|
+
when String then @bytes += value.bytesize
|
|
202
|
+
when Symbol then @bytes += value.to_s.bytesize
|
|
203
|
+
else @bytes += JSON.generate(value).bytesize
|
|
204
|
+
end
|
|
205
|
+
rescue JSON::GeneratorError, JSON::NestingError, Encoding::UndefinedConversionError
|
|
206
|
+
@bytes += value.to_s.bytesize
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module Budget
|
|
5
|
+
# Reserving for spending that has already happened (a batch result
|
|
6
|
+
# delivered after its request was admitted): never refused, so the
|
|
7
|
+
# reservation can be reconciled with what was spent, past the limits if
|
|
8
|
+
# need be. Part of Ledger.
|
|
9
|
+
module SpentReservation
|
|
10
|
+
def reserve_spent!(reservations)
|
|
11
|
+
amounts = validated_amounts(reservations, "budget reservation")
|
|
12
|
+
@mutex.synchronize { create_reservation(amounts, checked: false) }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
Smith::Budget::Ledger.include(Smith::Budget::SpentReservation)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
# One run a `map`, `call` or `spawn` step asks the host's run launcher to
|
|
7
|
+
# start: its `ref` (the index of its item, so the same on every replay of
|
|
8
|
+
# the step), its `key` (the step's token and the ref together: what the
|
|
9
|
+
# launcher must be idempotent on, unique across every child-run step of
|
|
10
|
+
# every run), its workflow class, the context `input:` returned for it,
|
|
11
|
+
# the untrusted keys it inherits from its parent (the parent's untrusted
|
|
12
|
+
# keys that `input:` passed under the same names), and the artifact
|
|
13
|
+
# namespaces it may read (its parent's and the parent's ancestors', the
|
|
14
|
+
# nearest first), so a document a parent stored reaches its children.
|
|
15
|
+
ChildRun = Data.define(:ref, :key, :workflow, :input, :untrusted_keys, :artifact_namespaces) do
|
|
16
|
+
def self.key_for(await_token, ref) = Digest::SHA256.hexdigest("smith.child_run.v1:#{await_token}:#{ref}")
|
|
17
|
+
|
|
18
|
+
def initialize(artifact_namespaces: [], **)
|
|
19
|
+
super
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The child, built with what it inherits in force; both persist with
|
|
23
|
+
# it, so a restored child keeps them.
|
|
24
|
+
def build
|
|
25
|
+
Workflow::StepIO.with_inherited_untrusted_keys(untrusted_keys) do
|
|
26
|
+
Workflow::ArtifactIntegration.with_inherited_artifact_namespaces(artifact_namespaces) do
|
|
27
|
+
workflow.new(context: input)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The entry a host reports for a finished child when it resumes the
|
|
33
|
+
# parent: `{ ref:, status: "succeeded" | "failed", state:, output: }`.
|
|
34
|
+
# A child failed when it ended in its failed state or any of its steps
|
|
35
|
+
# raised, even one its own `on_failure` handled.
|
|
36
|
+
def self.result(ref:, run_result:)
|
|
37
|
+
failed = run_result.failed? || Array(run_result.steps).any? { _1.is_a?(Hash) && _1.key?(:error) }
|
|
38
|
+
{ ref:, status: failed ? "failed" : "succeeded", state: run_result.state&.to_s, output: run_result.output }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
require_relative "diagnostic_text"
|
|
5
|
+
|
|
6
|
+
module Smith
|
|
7
|
+
# A `map` or `call` step whose child runs did not all succeed, under
|
|
8
|
+
# `on_item_failure: :fail`. Its failure family is `child_runs_failed`;
|
|
9
|
+
# details name the transition, up to MAX_REFS failed refs and the counts,
|
|
10
|
+
# and persist with the failed step.
|
|
11
|
+
class ChildRunsFailed < WorkflowError
|
|
12
|
+
DETAIL_NAMES = %i[transition failed_refs failed total].freeze
|
|
13
|
+
DETAIL_KEYS = DETAIL_NAMES.to_h { |name| [name.to_s.freeze, name] }.freeze
|
|
14
|
+
MAX_REFS = 20
|
|
15
|
+
private_constant :DETAIL_NAMES, :DETAIL_KEYS
|
|
16
|
+
|
|
17
|
+
attr_reader :transition, :failed_refs, :failed, :total
|
|
18
|
+
|
|
19
|
+
def initialize(transition:, failed_refs:, total:, failed: failed_refs.length, message: nil)
|
|
20
|
+
@transition = DiagnosticText.capture(transition.to_s, max_bytes: 256)
|
|
21
|
+
@failed_refs = Array(failed_refs).first(MAX_REFS).freeze
|
|
22
|
+
@failed = failed
|
|
23
|
+
@total = total
|
|
24
|
+
super(message || "#{failed} of #{total} child runs of #{@transition} failed (refs #{@failed_refs.join(", ")})")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def details = { transition:, failed_refs:, failed:, total: }.freeze
|
|
28
|
+
|
|
29
|
+
def self.from_details(details, message: nil)
|
|
30
|
+
raise ArgumentError, "child run failure details must be a Hash" unless details.is_a?(Hash)
|
|
31
|
+
|
|
32
|
+
values = details.to_h { |key, value| [key.is_a?(Symbol) ? key : DETAIL_KEYS[key], value] }
|
|
33
|
+
unless values.keys.sort == DETAIL_NAMES.sort && valid_counts?(values)
|
|
34
|
+
raise ArgumentError, "child run failure details are invalid"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
new(transition: values[:transition], failed_refs: values[:failed_refs], failed: values[:failed],
|
|
38
|
+
total: values[:total], message:)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Counts within bounds, and refs that are distinct indexes of the total,
|
|
42
|
+
# as many as the failures up to MAX_REFS (so neither count is negative).
|
|
43
|
+
def self.valid_counts?(values)
|
|
44
|
+
refs, failed, total = values.values_at(:failed_refs, :failed, :total)
|
|
45
|
+
return false unless values[:transition].is_a?(String) && [failed, total].all?(Integer)
|
|
46
|
+
|
|
47
|
+
failed <= total && valid_refs?(refs, failed, total)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.valid_refs?(refs, failed, total)
|
|
51
|
+
refs.is_a?(Array) && refs.all? { _1.is_a?(Integer) && _1.between?(0, total - 1) } &&
|
|
52
|
+
refs.length == [failed, MAX_REFS].min && refs.uniq.length == refs.length
|
|
53
|
+
end
|
|
54
|
+
private_class_method :valid_counts?, :valid_refs?
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
# Content parts of a Smith::Message: text, and documents and images held
|
|
7
|
+
# as artifacts of the run (`Smith.artifacts.store` inside a step), never
|
|
8
|
+
# as bytes, so a message with parts stays small and JSON-shaped wherever
|
|
9
|
+
# it is stored or digested.
|
|
10
|
+
#
|
|
11
|
+
# ref = Smith.artifacts.store(pdf_bytes, content_type: "application/pdf")
|
|
12
|
+
# Smith::Message.user([Smith::Content.document(ref, media_type: "application/pdf"),
|
|
13
|
+
# Smith::Content.text("List the parties to this contract.")])
|
|
14
|
+
#
|
|
15
|
+
# `cache: true` marks the part as the end of a prefix Anthropic may cache;
|
|
16
|
+
# other providers cache on their own and ignore it.
|
|
17
|
+
module Content
|
|
18
|
+
DOCUMENT_TYPES = %w[application/pdf text/plain].freeze
|
|
19
|
+
IMAGE_TYPES = %w[image/png image/jpeg image/gif image/webp].freeze
|
|
20
|
+
MAX_TEXT_BYTES = 1024 * 1024
|
|
21
|
+
MAX_REF_BYTES = 1024
|
|
22
|
+
# The bytes one document part may hold when it is sent: as base64 it
|
|
23
|
+
# still fits a 32 MiB request.
|
|
24
|
+
MAX_PART_BYTES = 24 * 1024 * 1024
|
|
25
|
+
# An artifact ref as Smith's stores make them: a run's namespace, then
|
|
26
|
+
# colon-separated names of letters, digits, `-` and `_`. No path, dot or
|
|
27
|
+
# NUL can reach a store through it.
|
|
28
|
+
REF = /\A[A-Za-z0-9_-]+(?::[A-Za-z0-9_-]+)+\z/
|
|
29
|
+
|
|
30
|
+
module_function
|
|
31
|
+
|
|
32
|
+
def text(text, cache: false) = part({ "type" => "text", "text" => text }, cache)
|
|
33
|
+
|
|
34
|
+
def document(ref, media_type:, cache: false)
|
|
35
|
+
part({ "type" => "document", "artifact" => ref, "media_type" => media_type }, cache)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def image(ref, media_type:, cache: false)
|
|
39
|
+
part({ "type" => "image", "artifact" => ref, "media_type" => media_type }, cache)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def part(fields, cache)
|
|
43
|
+
raise WorkflowError, "a content part's cache: must be true or false" unless [true, false].include?(cache)
|
|
44
|
+
|
|
45
|
+
check!(cache ? fields.merge("cache" => true) : fields)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# A part, String or Symbol keyed (and Symbol valued), checked and
|
|
49
|
+
# returned frozen with String keys and UTF-8 Strings: a text part with
|
|
50
|
+
# more than whitespace, or a document or image part naming an artifact
|
|
51
|
+
# (REF) and a media type of its kind, each with an optional
|
|
52
|
+
# `"cache" => true`.
|
|
53
|
+
def check!(value)
|
|
54
|
+
fields = value.is_a?(Hash) ? value.to_h { |key, field| [key.to_s, utf8(field)] } : {}
|
|
55
|
+
unless valid?(fields)
|
|
56
|
+
raise WorkflowError, "a content part is Smith::Content.text, .document or .image (documents " \
|
|
57
|
+
"#{DOCUMENT_TYPES.join(", ")}; images #{IMAGE_TYPES.join(", ")})"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
fields.freeze
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def utf8(field)
|
|
64
|
+
return field.to_s.freeze if field.is_a?(Symbol)
|
|
65
|
+
return field unless field.is_a?(String)
|
|
66
|
+
|
|
67
|
+
field.dup.force_encoding(Encoding::UTF_8).freeze
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def valid?(fields)
|
|
71
|
+
return false unless fields.fetch("cache", true) == true
|
|
72
|
+
|
|
73
|
+
keys = fields.keys - ["cache"]
|
|
74
|
+
case fields["type"]
|
|
75
|
+
when "text" then keys.sort == %w[text type] && text?(fields["text"])
|
|
76
|
+
when "document" then held?(keys, fields, DOCUMENT_TYPES)
|
|
77
|
+
when "image" then held?(keys, fields, IMAGE_TYPES)
|
|
78
|
+
else false
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def text?(text) = bounded?(text, MAX_TEXT_BYTES) && text.match?(/\S/)
|
|
83
|
+
|
|
84
|
+
def held?(keys, fields, media_types)
|
|
85
|
+
keys.sort == %w[artifact media_type type] && bounded?(fields["artifact"], MAX_REF_BYTES) &&
|
|
86
|
+
REF.match?(fields["artifact"]) && media_types.include?(fields["media_type"])
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def bounded?(value, max_bytes)
|
|
90
|
+
value.is_a?(String) && value.valid_encoding? && value.bytesize.between?(1, max_bytes)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def cached?(part) = part["cache"] == true
|
|
94
|
+
private_class_method :part, :utf8, :valid?, :text?, :held?, :bounded?
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -5,11 +5,12 @@ module Smith
|
|
|
5
5
|
class Session
|
|
6
6
|
attr_reader :messages
|
|
7
7
|
|
|
8
|
-
def initialize(messages:, context_manager:, persisted_context:, seed_message_count: 0)
|
|
8
|
+
def initialize(messages:, context_manager:, persisted_context:, seed_message_count: 0, untrusted_keys: nil)
|
|
9
9
|
@messages = messages
|
|
10
10
|
@context_manager = context_manager
|
|
11
11
|
@persisted_context = persisted_context
|
|
12
12
|
@seed_message_count = seed_message_count
|
|
13
|
+
@untrusted_keys = untrusted_keys
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
def inject_state!
|
|
@@ -19,10 +20,21 @@ module Smith
|
|
|
19
20
|
return unless formatter
|
|
20
21
|
|
|
21
22
|
@messages.replace(
|
|
22
|
-
StateInjection.inject(@messages, formatter: formatter, persisted:
|
|
23
|
+
StateInjection.inject(@messages, formatter: formatter, persisted: trusted_context)
|
|
23
24
|
)
|
|
24
25
|
end
|
|
25
26
|
|
|
27
|
+
# The context without its untrusted keys: the run's own and those of
|
|
28
|
+
# any workflow it is nested in, when the run passes them, or else its
|
|
29
|
+
# context manager's.
|
|
30
|
+
def trusted_context
|
|
31
|
+
untrusted = @untrusted_keys
|
|
32
|
+
untrusted ||= @context_manager.respond_to?(:untrusted_keys) ? @context_manager.untrusted_keys : []
|
|
33
|
+
return @persisted_context if untrusted.empty? || !@persisted_context.is_a?(Hash)
|
|
34
|
+
|
|
35
|
+
@persisted_context.except(*untrusted)
|
|
36
|
+
end
|
|
37
|
+
|
|
26
38
|
def masked_view
|
|
27
39
|
return @messages unless @context_manager
|
|
28
40
|
|