smith-agents 0.11.0 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +712 -0
- data/README.md +622 -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 +67 -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
data/lib/smith/tool.rb
CHANGED
|
@@ -148,6 +148,20 @@ module Smith
|
|
|
148
148
|
|
|
149
149
|
private
|
|
150
150
|
|
|
151
|
+
# A key for a request this tool sends outside the system: unique to the
|
|
152
|
+
# run, the step and branch the tool runs in, this tool's `name` and
|
|
153
|
+
# `scope`, and the same when a replay sends the same request. A tool can
|
|
154
|
+
# run several times in one step, and a replayed model may word or order
|
|
155
|
+
# its calls differently, so `scope` is required and should be the
|
|
156
|
+
# request's stable identity (`idempotency_key(customer_id:, invoice:)`),
|
|
157
|
+
# not free text. Renaming the tool changes its keys. Raises
|
|
158
|
+
# Smith::WorkflowError outside a workflow step.
|
|
159
|
+
def idempotency_key(scope)
|
|
160
|
+
raise WorkflowError, "an idempotency key needs a tool with a name" if name.to_s.empty?
|
|
161
|
+
|
|
162
|
+
IdempotencyKey.__send__(:current_scope!).within(:tool, name).key(scope)
|
|
163
|
+
end
|
|
164
|
+
|
|
151
165
|
def run_before_execute_hook!(kwargs)
|
|
152
166
|
hook = self.class.before_execute
|
|
153
167
|
return unless hook
|
data/lib/smith/trace/logger.rb
CHANGED
data/lib/smith/trace/memory.rb
CHANGED
data/lib/smith/trace.rb
CHANGED
|
@@ -72,9 +72,12 @@ module Smith
|
|
|
72
72
|
end
|
|
73
73
|
end
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
# Masks content only: every String and Symbol inside the values of
|
|
76
|
+
# `content_keys`, at any depth. Identifiers (model, execution key, tool
|
|
77
|
+
# name, error class) stay readable.
|
|
78
|
+
def self.redact_sensitive_keys(data, content_keys = SENSITIVITY_CONTENT_KEYS)
|
|
76
79
|
data.each_with_object({}) do |(key, value), filtered|
|
|
77
|
-
filtered[key] = if
|
|
80
|
+
filtered[key] = if content_keys.include?(key)
|
|
78
81
|
redact_value(value)
|
|
79
82
|
else
|
|
80
83
|
value
|
|
@@ -82,12 +85,16 @@ module Smith
|
|
|
82
85
|
end
|
|
83
86
|
end
|
|
84
87
|
|
|
88
|
+
# Every String and Symbol leaf at any depth of Hashes and Arrays;
|
|
89
|
+
# numbers, booleans and nil are kept.
|
|
85
90
|
def self.redact_value(value)
|
|
86
91
|
case value
|
|
87
|
-
when String
|
|
92
|
+
when String, Symbol
|
|
88
93
|
"[REDACTED]"
|
|
89
94
|
when Hash
|
|
90
|
-
value.transform_values { |
|
|
95
|
+
value.transform_values { |nested| redact_value(nested) }
|
|
96
|
+
when Array
|
|
97
|
+
value.map { |nested| redact_value(nested) }
|
|
91
98
|
else
|
|
92
99
|
value
|
|
93
100
|
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module Smith
|
|
7
|
+
# Delimits text from outside the system (a fetched document, an inbound
|
|
8
|
+
# message) before it goes into a prompt, so the model can tell it apart
|
|
9
|
+
# from instructions:
|
|
10
|
+
#
|
|
11
|
+
# Smith::Untrusted.wrap(document_text, source: "document")
|
|
12
|
+
# # => <untrusted source="document" boundary="9f2c...">
|
|
13
|
+
# # ...text...
|
|
14
|
+
# # </untrusted boundary="9f2c...">
|
|
15
|
+
#
|
|
16
|
+
# The text cannot guess the boundary. Inside a workflow step it is derived
|
|
17
|
+
# from the run's namespace (a random UUID), the step's position, the
|
|
18
|
+
# source and the text, so a step that prepares its input again after a
|
|
19
|
+
# crash gets the same message; anywhere else it is random.
|
|
20
|
+
#
|
|
21
|
+
# Wrapping changes the text so it cannot close the block or pose as
|
|
22
|
+
# another turn: anything shaped like an untrusted tag (spaces, invisible
|
|
23
|
+
# characters or full-width forms included) becomes `untrusted-text`, and
|
|
24
|
+
# a role label (`system:`, `assistant:`, `user:`, `tool:`, `developer:`,
|
|
25
|
+
# `human:`, `ai:`) that opens any line gains "(quoted text)", also behind
|
|
26
|
+
# spaces, invisible characters, brackets, quotes, `#`, `>`, `*`, `_` or
|
|
27
|
+
# `-`. Ordinary text with such a line (`user: alice` in YAML) changes the
|
|
28
|
+
# same way. Markdown headings, XML-like tags such as `<system>` and
|
|
29
|
+
# look-alike letters are left as they are. Wrapping does not make text
|
|
30
|
+
# safe to act on; it only marks where it starts and ends.
|
|
31
|
+
module Untrusted
|
|
32
|
+
GAP = "\\p{Cf}*"
|
|
33
|
+
ROLE_WORDS = %w[system assistant user tool developer human ai].map { |role| role.chars.join(GAP) }.join("|")
|
|
34
|
+
ROLE_LABEL = /
|
|
35
|
+
(\A|[\n\r\v\f\u0085\u2028\u2029])
|
|
36
|
+
([\p{Space}\p{Cf}#>\[(*_"'`-]*)
|
|
37
|
+
(#{ROLE_WORDS})
|
|
38
|
+
([\p{Space}\p{Cf}*_\])"'`]*[:\uFF1A\uFE55])
|
|
39
|
+
/ix
|
|
40
|
+
TAG = %r{[<\uFF1C][\p{Space}\p{Cf}\p{Cc}]*([/\uFF0F]?)[\p{Space}\p{Cf}\p{Cc}]*#{"untrusted".chars.join(GAP)}}i
|
|
41
|
+
SOURCE = %r{\A[\w .:/-]{1,64}\z}
|
|
42
|
+
|
|
43
|
+
module_function
|
|
44
|
+
|
|
45
|
+
def wrap(text, source:)
|
|
46
|
+
raise ArgumentError, "untrusted text must be a String" unless text.is_a?(String)
|
|
47
|
+
|
|
48
|
+
label = source.to_s
|
|
49
|
+
unless label.is_a?(String) && SOURCE.match?(label)
|
|
50
|
+
raise ArgumentError, "untrusted source must be 1 to 64 letters, digits, spaces or . : / - _"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
body = escape(text)
|
|
54
|
+
boundary = boundary_for(label, body)
|
|
55
|
+
%(<untrusted source="#{label}" boundary="#{boundary}">\n#{body}\n</untrusted boundary="#{boundary}">)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def escape(text)
|
|
59
|
+
utf8 = text.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
|
|
60
|
+
utf8.gsub(TAG) { "<#{"/" unless Regexp.last_match(1).empty?}untrusted-text" }
|
|
61
|
+
.gsub(ROLE_LABEL) { Regexp.last_match.captures.insert(3, " (quoted text)").join }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# A position no idempotency key uses, so no key a step hands to another
|
|
65
|
+
# service gives the boundary away.
|
|
66
|
+
def boundary_for(label, body)
|
|
67
|
+
scope = IdempotencyKey.__send__(:current_scope)
|
|
68
|
+
return SecureRandom.hex(12) unless scope
|
|
69
|
+
|
|
70
|
+
scope.within(:untrusted).key([label, Digest::SHA256.hexdigest(body)])[0, 24]
|
|
71
|
+
end
|
|
72
|
+
private_class_method :boundary_for
|
|
73
|
+
end
|
|
74
|
+
end
|
data/lib/smith/version.rb
CHANGED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
# `wall_clock` bounds the time the run's steps spend running, not the
|
|
6
|
+
# time since it was created: a run that waits for days, or whose host
|
|
7
|
+
# advances it step by step from a queue, spends only its active time.
|
|
8
|
+
# Each step's deadline is its start plus what is left of the budget, and
|
|
9
|
+
# the step's monotonic duration is added to the persisted
|
|
10
|
+
# `active_seconds` when it ends.
|
|
11
|
+
module ActiveClock
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def start_active_clock!
|
|
15
|
+
@active_clock_started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
16
|
+
@active_clock_started_at = Time.now.utc
|
|
17
|
+
remove_instance_variable(:@wall_clock_deadline) if defined?(@wall_clock_deadline)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def stop_active_clock!
|
|
21
|
+
started = @active_clock_started
|
|
22
|
+
return unless started
|
|
23
|
+
|
|
24
|
+
@active_clock_started = nil
|
|
25
|
+
@active_seconds = (@active_seconds || 0.0) + (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def active_deadline(limit)
|
|
29
|
+
(@active_clock_started_at || Time.now.utc) + (limit - (@active_seconds || 0.0))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The checkpoint written before a step records when it began; the one
|
|
33
|
+
# written after clears it. A run restored with the mark still set had a
|
|
34
|
+
# step that raised or whose process died before finishing, so the time
|
|
35
|
+
# since that step began is charged, at most what is left of the budget,
|
|
36
|
+
# which keeps a run that keeps failing bounded across retries.
|
|
37
|
+
def mark_active_step_start!(running)
|
|
38
|
+
@active_step_started_at = running ? Time.now.utc.iso8601(6) : nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def charge_unfinished_step!(started_at)
|
|
42
|
+
@active_step_started_at = nil
|
|
43
|
+
return if started_at.nil?
|
|
44
|
+
|
|
45
|
+
elapsed = [Time.now.utc - Time.iso8601(started_at.to_s), 0.0].max
|
|
46
|
+
limit = self.class.budget&.dig(:wall_clock)
|
|
47
|
+
elapsed = elapsed.clamp(0.0, [limit - @active_seconds, 0.0].max) if limit
|
|
48
|
+
@active_seconds += elapsed
|
|
49
|
+
rescue ArgumentError
|
|
50
|
+
raise Smith::SerializationError, "persisted workflow active_step_started_at must be an ISO 8601 time"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def restored_active_seconds(value)
|
|
54
|
+
return 0.0 if value.nil?
|
|
55
|
+
return value.to_f if (value.is_a?(Integer) || (value.is_a?(Float) && value.finite?)) && !value.negative?
|
|
56
|
+
|
|
57
|
+
raise Smith::SerializationError, "persisted workflow active_seconds must be a non-negative number"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -3,10 +3,24 @@
|
|
|
3
3
|
module Smith
|
|
4
4
|
class Workflow
|
|
5
5
|
# rubocop:disable Style/RedundantStructKeywordInit
|
|
6
|
+
# `input_tokens` counts uncached input; `cache_read_tokens` and
|
|
7
|
+
# `cache_write_tokens` are the prompt-cache input beside it, and
|
|
8
|
+
# `total_tokens` adds all three to the output. `mode` is how the call was
|
|
9
|
+
# made, set by streaming and batch steps (nil for an ordinary call).
|
|
6
10
|
AgentResult = Struct.new(
|
|
7
11
|
:content, :input_tokens, :output_tokens, :cost, :model_used, :provider_used,
|
|
12
|
+
:cache_read_tokens, :cache_write_tokens, :thinking_tokens, :stop_reason, :provider_message_id, :mode,
|
|
8
13
|
keyword_init: true
|
|
9
14
|
) do
|
|
15
|
+
# The detail members of an AgentResult taken from a ProviderUsage.
|
|
16
|
+
def self.usage_details(usage)
|
|
17
|
+
return {} unless usage
|
|
18
|
+
|
|
19
|
+
[*self::USAGE_DETAIL_MEMBERS, :mode].to_h do |member|
|
|
20
|
+
[member, usage.respond_to?(member) ? usage.public_send(member) : nil]
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
10
24
|
def self.from_response(response, content, model_used: nil, provider_used: nil)
|
|
11
25
|
new(
|
|
12
26
|
content: content,
|
|
@@ -18,10 +32,33 @@ module Smith
|
|
|
18
32
|
)
|
|
19
33
|
end
|
|
20
34
|
|
|
35
|
+
# Not members, so they stay out of to_h, to_a and ==. metered_tokens
|
|
36
|
+
# is set on a partially metered invocation (some response reported no
|
|
37
|
+
# usage) to the tokens its metered responses recorded; cache_unpriced
|
|
38
|
+
# says cache tokens were priced at 0 for lack of a catalog rate.
|
|
39
|
+
attr_accessor :metered_tokens, :cache_unpriced
|
|
40
|
+
|
|
21
41
|
def usage_known?
|
|
22
42
|
!input_tokens.nil? && !output_tokens.nil?
|
|
23
43
|
end
|
|
44
|
+
|
|
45
|
+
# The tokens a budget settles: the total when usage is known, the
|
|
46
|
+
# metered part of a partially metered invocation, otherwise every
|
|
47
|
+
# count that is known.
|
|
48
|
+
def billable_tokens
|
|
49
|
+
total_tokens || metered_tokens ||
|
|
50
|
+
[input_tokens, output_tokens, cache_read_tokens, cache_write_tokens].sum { |count| count || 0 }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def total_tokens
|
|
54
|
+
return unless usage_known?
|
|
55
|
+
|
|
56
|
+
input_tokens + output_tokens + (cache_read_tokens || 0) + (cache_write_tokens || 0)
|
|
57
|
+
end
|
|
24
58
|
end
|
|
59
|
+
AgentResult::USAGE_DETAIL_MEMBERS = %i[
|
|
60
|
+
cache_read_tokens cache_write_tokens thinking_tokens stop_reason provider_message_id
|
|
61
|
+
].freeze
|
|
25
62
|
# rubocop:enable Style/RedundantStructKeywordInit
|
|
26
63
|
end
|
|
27
64
|
end
|
|
@@ -5,18 +5,67 @@ require "securerandom"
|
|
|
5
5
|
module Smith
|
|
6
6
|
class Workflow
|
|
7
7
|
module ArtifactIntegration
|
|
8
|
+
# This run's view of the artifact store, for the host after or between
|
|
9
|
+
# its steps: read the refs the run stored (or its ancestors'), list
|
|
10
|
+
# its expired refs and delete them. Inside a step, Smith.artifacts is
|
|
11
|
+
# the same view.
|
|
12
|
+
def artifacts
|
|
13
|
+
Artifacts::ScopedStore.new(namespace: execution_namespace, readable: @inherited_artifact_namespaces || [])
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# How many ancestors' artifacts a child run may read: the nearest
|
|
17
|
+
# ones, so a deep recursion reads its near ancestors and no others.
|
|
18
|
+
MAX_INHERITED_NAMESPACES = 16
|
|
19
|
+
INHERITED_NAMESPACES = :smith_inherited_artifact_namespaces
|
|
20
|
+
private_constant :INHERITED_NAMESPACES
|
|
21
|
+
|
|
22
|
+
# Builds a child run with the namespaces whose artifacts it may read
|
|
23
|
+
# in force from construction; they persist with it.
|
|
24
|
+
def self.with_inherited_artifact_namespaces(namespaces)
|
|
25
|
+
outer = Thread.current[INHERITED_NAMESPACES]
|
|
26
|
+
Thread.current[INHERITED_NAMESPACES] = artifact_namespaces!(namespaces)
|
|
27
|
+
yield
|
|
28
|
+
ensure
|
|
29
|
+
Thread.current[INHERITED_NAMESPACES] = outer
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# At most MAX_INHERITED_NAMESPACES Strings of 1 to 256 characters.
|
|
33
|
+
def self.artifact_namespaces!(namespaces)
|
|
34
|
+
valid = namespaces.is_a?(Array) && namespaces.length <= MAX_INHERITED_NAMESPACES &&
|
|
35
|
+
namespaces.all? { _1.is_a?(String) && _1.length.between?(1, 256) }
|
|
36
|
+
raise WorkflowError, "artifact namespaces must be up to #{MAX_INHERITED_NAMESPACES} names" unless valid
|
|
37
|
+
|
|
38
|
+
namespaces.uniq.map { _1.dup.freeze }.freeze
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Taken once, so a workflow its constructor builds does not inherit
|
|
42
|
+
# them too.
|
|
43
|
+
def self.take_inherited_artifact_namespaces
|
|
44
|
+
namespaces = Thread.current[INHERITED_NAMESPACES]
|
|
45
|
+
Thread.current[INHERITED_NAMESPACES] = nil
|
|
46
|
+
namespaces || [].freeze
|
|
47
|
+
end
|
|
48
|
+
|
|
8
49
|
private
|
|
9
50
|
|
|
51
|
+
# The step's artifacts are scoped to its run (or, in a nested
|
|
52
|
+
# workflow, its parent's), over the configured store itself even when
|
|
53
|
+
# the step runs inside another run's step (an inline child run),
|
|
54
|
+
# resolved when the step first stores or reads. The step context's
|
|
55
|
+
# snapshot restores the outer scope when the step ends.
|
|
10
56
|
def with_scoped_artifacts
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
backend = Smith.config.artifact_store || Smith.artifacts
|
|
15
|
-
Smith.scoped_artifacts = Artifacts::ScopedStore.new(backend: backend, namespace: execution_namespace)
|
|
16
|
-
end
|
|
57
|
+
Smith.scoped_artifacts = @inherited_scoped_artifacts ||
|
|
58
|
+
Artifacts::ScopedStore.new(namespace: execution_namespace,
|
|
59
|
+
readable: @inherited_artifact_namespaces || [])
|
|
17
60
|
yield
|
|
18
61
|
end
|
|
19
62
|
|
|
63
|
+
# What a child run of this run may read: this run's namespace, then
|
|
64
|
+
# the ones it may read itself, the nearest first.
|
|
65
|
+
def child_artifact_namespaces
|
|
66
|
+
[execution_namespace, *(@inherited_artifact_namespaces || [])].first(MAX_INHERITED_NAMESPACES).freeze
|
|
67
|
+
end
|
|
68
|
+
|
|
20
69
|
def execution_namespace
|
|
21
70
|
@execution_namespace ||= SecureRandom.uuid
|
|
22
71
|
end
|
|
@@ -34,7 +83,8 @@ module Smith
|
|
|
34
83
|
messages: messages,
|
|
35
84
|
context_manager: manager,
|
|
36
85
|
persisted_context: @context,
|
|
37
|
-
seed_message_count: @seed_message_count || 0
|
|
86
|
+
seed_message_count: @seed_message_count || 0,
|
|
87
|
+
untrusted_keys: untrusted_context_keys
|
|
38
88
|
)
|
|
39
89
|
end
|
|
40
90
|
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
# What an `await` block receives when the step is entered. It reads the
|
|
6
|
+
# context, names the wait (`await_token`, the key the request the block
|
|
7
|
+
# sends must carry, the same each time the block runs again after a
|
|
8
|
+
# crash), and may finish the wait at once with `resolve!`. It cannot
|
|
9
|
+
# write the context: the payload `resolve!` or `resume!` delivers is
|
|
10
|
+
# written to the step's `into:` key.
|
|
11
|
+
class AwaitStep
|
|
12
|
+
attr_reader :context, :await_token, :attempt, :transition_name, :payload
|
|
13
|
+
|
|
14
|
+
def initialize(context:, await_token:, idempotency_scope:, attempt:, transition_name:)
|
|
15
|
+
@context = context
|
|
16
|
+
@await_token = await_token
|
|
17
|
+
@idempotency_scope = idempotency_scope
|
|
18
|
+
@attempt = attempt
|
|
19
|
+
@transition_name = transition_name
|
|
20
|
+
@resolved = false
|
|
21
|
+
@payload = nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def read_context(key) = @context[key]
|
|
25
|
+
|
|
26
|
+
# A key for another request the block sends, as on any step.
|
|
27
|
+
def idempotency_key(scope = nil) = @idempotency_scope.key(scope)
|
|
28
|
+
|
|
29
|
+
# Finishes the wait inline with `payload`, which is written to `into:`
|
|
30
|
+
# as a delivered event's would be.
|
|
31
|
+
def resolve!(payload)
|
|
32
|
+
raise WorkflowError, "resolve! was already called" if @resolved
|
|
33
|
+
|
|
34
|
+
@payload = MessageValueNormalizer.new(payload, label: "wait payload").call
|
|
35
|
+
@resolved = true
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def resolved? = @resolved
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
# What batch results cost (BatchWait). A call whose result has come is
|
|
6
|
+
# reserved for whatever the budget has left, never refused, as the money
|
|
7
|
+
# is spent; a succeeded result no call took is recorded as a billed
|
|
8
|
+
# response of its branch's agent (`:failed_attempt`, `mode: :batch`) and
|
|
9
|
+
# charged to the run's budget.
|
|
10
|
+
module BatchCharges
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def reserve_estimates!(ledger, estimates)
|
|
14
|
+
return super unless batch_result_due?
|
|
15
|
+
|
|
16
|
+
Thread.current[:smith_call_reservation] = ledger.reserve_spent!(estimates)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def charge_untaken_batch_results(transition)
|
|
20
|
+
@batch_delivery.untaken.each do |branch_key, entry|
|
|
21
|
+
next unless entry.result["type"] == "succeeded"
|
|
22
|
+
|
|
23
|
+
Attribution.with(branch_key:) do
|
|
24
|
+
charge_batch_result(batch_agent_class(transition, branch_key), entry.result.fetch("message"))
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def batch_agent_class(transition, branch_key)
|
|
30
|
+
return resolve_agent_class(transition) unless branch_key
|
|
31
|
+
|
|
32
|
+
resolve_fanout_agent_class(transition, transition.fanout_config.fetch(:branches).fetch(branch_key))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def charge_batch_result(agent_class, message)
|
|
36
|
+
usage = batch_reply_usage(message)
|
|
37
|
+
return unless usage
|
|
38
|
+
|
|
39
|
+
model = Agent::ModelReference.new(model_id: batch_model_id(agent_class, message), provider: "anthropic")
|
|
40
|
+
record_billed_usages(agent_class, [usage], :failed_attempt, model, attempt_id: nil)
|
|
41
|
+
settle_spent_batch_charges
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The id a call of the agent prices its usage under, as RubyLLM
|
|
45
|
+
# resolves the agent's model (an alias to its dated id), else the
|
|
46
|
+
# result's own (a model given by a block).
|
|
47
|
+
def batch_model_id(agent_class, message)
|
|
48
|
+
kwargs = agent_class.chat_kwargs
|
|
49
|
+
return message["model"] unless kwargs[:model]
|
|
50
|
+
|
|
51
|
+
RubyLLM::Models.resolve(kwargs[:model], provider: kwargs[:provider],
|
|
52
|
+
assume_exists: kwargs[:assume_model_exists] || false).first.id
|
|
53
|
+
rescue StandardError
|
|
54
|
+
kwargs[:model]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# A result's usage, as the provider's own parser reads its message.
|
|
58
|
+
def batch_reply_usage(message)
|
|
59
|
+
reply = RubyLLM::Providers::Anthropic.new(RubyLLM.config)
|
|
60
|
+
.send(:parse_completion_response,
|
|
61
|
+
Providers::BatchExchange::Response.new(message.to_h))
|
|
62
|
+
Agent::ProviderUsage.from_message(reply)&.new(mode: :batch)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def settle_spent_batch_charges
|
|
66
|
+
ledger = effective_call_ledger
|
|
67
|
+
return clear_failed_billable_attempts unless ledger
|
|
68
|
+
|
|
69
|
+
reconcile_branch_budget(ledger, ledger.reserve_spent!(ledger.limits.keys.to_h { [_1, 0] }))
|
|
70
|
+
clear_failed_billable_attempts
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Workflow
|
|
7
|
+
# Batch mode (Smith::Batch). An attempt whose mode is :batch prepares its
|
|
8
|
+
# agent call as a synchronous one would, admits it against the budget,
|
|
9
|
+
# and captures the request instead of sending it (a fan-out captures each
|
|
10
|
+
# branch's, in turn). Once the step's attempt has ended without error
|
|
11
|
+
# (its context checks included), the requests go to
|
|
12
|
+
# Smith.config.batch_queue under ids of the wait's token and the run waits
|
|
13
|
+
# on `:batch_result`, recording its pending batch (the transition, the
|
|
14
|
+
# attempt, its key generation, and each request's digest). Like any wait
|
|
15
|
+
# it is not a step; BatchWait completes it. A step inside another run's
|
|
16
|
+
# step (an in-process child) cannot wait, so it never sends a batch.
|
|
17
|
+
module BatchExecution
|
|
18
|
+
# Batches end within 24 hours; the wait allows one more.
|
|
19
|
+
WAIT_SECONDS = 25 * 3600
|
|
20
|
+
# The requests an attempt captured, for the step to queue.
|
|
21
|
+
Captured = Data.define(:token, :requests, :attempt, :key_generation)
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
# The attempt sends its requests as a batch: its mode says so and no
|
|
26
|
+
# result of it is being delivered.
|
|
27
|
+
def batch_send?(transition)
|
|
28
|
+
transition.batches? && !delivering_batch? && batch_mode_for(transition) == :batch
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# A delivery runs only the stored batch wait's step (BatchWait).
|
|
32
|
+
def delivering_batch? = !@batch_delivery.nil? && @pending_batch["attempt"] == (@step_attempt || 1)
|
|
33
|
+
|
|
34
|
+
def batch_mode_for(transition)
|
|
35
|
+
mode = transition.batch_mode
|
|
36
|
+
return mode unless mode.respond_to?(:call)
|
|
37
|
+
|
|
38
|
+
context = snapshot_value(@context)
|
|
39
|
+
arity = mode.is_a?(Proc) ? mode.arity : mode.method(:call).arity
|
|
40
|
+
value = arity == 1 ? mode.call(context) : mode.call(context, @step_attempt || 1)
|
|
41
|
+
return value if %i[sync batch].include?(value)
|
|
42
|
+
|
|
43
|
+
raise WorkflowError, "a mode: callable must return :sync or :batch, got #{value.inspect}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# An agent step's batch: its one call.
|
|
47
|
+
def send_agent_batch(transition, agent_class, prepared_input)
|
|
48
|
+
send_batch(transition, nil => lambda do
|
|
49
|
+
admit_batch_call { reserve_for_serial(transition, effective_call_ledger) }
|
|
50
|
+
with_agent_context(agent_class) { capture_batch_request(agent_class, prepared_input) }
|
|
51
|
+
end)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# A fan-out's batch: each branch's call, captured in its own branch
|
|
55
|
+
# context (attribution, idempotency scope, budget share) in turn.
|
|
56
|
+
def send_fanout_batch(transition, branches, branch_agent_classes, prepared_input)
|
|
57
|
+
env = fanout_branch_environment(branches, branch_agent_classes, prepared_input)
|
|
58
|
+
captures = branches.to_h do |branch_key, _agent_name|
|
|
59
|
+
agent_class = branch_agent_classes.fetch(branch_key)
|
|
60
|
+
[branch_key, -> { capture_fanout_branch(env, branch_key, agent_class) }]
|
|
61
|
+
end
|
|
62
|
+
send_batch(transition, captures)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def capture_fanout_branch(env, branch_key, agent_class)
|
|
66
|
+
with_branch_context(env, @ledger, agent_class:, attribution_overlay: { branch_key: }) do
|
|
67
|
+
with_agent_context(agent_class) do
|
|
68
|
+
admit_batch_call do
|
|
69
|
+
reserve_fanout_branch_call(effective_call_ledger, env.branch_estimates[branch_key], agent_class)
|
|
70
|
+
end
|
|
71
|
+
capture_batch_request(agent_class, env.prepared_input)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The budget admits the call now; what it costs is charged when its
|
|
77
|
+
# result comes.
|
|
78
|
+
def admit_batch_call
|
|
79
|
+
ledger = effective_call_ledger
|
|
80
|
+
release_branch_budget(ledger, yield)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# The request the agent's call would send (BatchExchange throws it out
|
|
84
|
+
# of the call before anything is sent), and its hold.
|
|
85
|
+
def capture_batch_request(agent_class, prepared_input)
|
|
86
|
+
@batch_capture = nil
|
|
87
|
+
request = catch(Providers::BatchExchange::CAPTURED) do
|
|
88
|
+
@batch_capturing = true
|
|
89
|
+
invoke_agent(agent_class, prepared_input)
|
|
90
|
+
nil
|
|
91
|
+
ensure
|
|
92
|
+
@batch_capturing = false
|
|
93
|
+
end
|
|
94
|
+
[request || raise(BatchModeUnavailable, "a batch step's agent made no provider request"), @batch_capture.hold]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def send_batch(transition, captures)
|
|
98
|
+
refuse_batch_wait!
|
|
99
|
+
token = wait_token_for(transition, ["batch", @step_attempt || 1])
|
|
100
|
+
captured = captures.transform_values(&:call)
|
|
101
|
+
hold_batch!(token, captured.transform_values(&:last))
|
|
102
|
+
Captured.new(token:, requests: captured.transform_values(&:first), attempt: @step_attempt || 1,
|
|
103
|
+
key_generation: @step_key_generation || 0)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The run can wait for a batch: a queue takes its requests, it is not
|
|
107
|
+
# inside split-step execution or another run's step (an in-process
|
|
108
|
+
# child), and the wait ends within the store's TTL.
|
|
109
|
+
def refuse_batch_wait!
|
|
110
|
+
Smith.config.batch_queue || raise(BatchQueueMissing)
|
|
111
|
+
raise WorkflowError, "a batch step cannot wait inside split-step execution" if @split_step_phase == :executing
|
|
112
|
+
raise WorkflowError, "an in-process child cannot wait for a batch" if Thread.current[:smith_step_depth].to_i > 1
|
|
113
|
+
|
|
114
|
+
check_wait_ttl!(WAIT_SECONDS)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Hands the captured requests to the queue and waits. The batch is the
|
|
118
|
+
# attempt a pending retry waited to make, so the pending batch takes
|
|
119
|
+
# its place.
|
|
120
|
+
def queue_batch(transition, captured)
|
|
121
|
+
ids = enqueue_requests(captured)
|
|
122
|
+
@batch_holds = nil
|
|
123
|
+
@pending_retry = nil
|
|
124
|
+
@pending_batch = pending_batch_record(transition, captured, ids)
|
|
125
|
+
@awaiting = WaitRecord.build_batch(transition, captured.token, WAIT_SECONDS, Time.now.utc, ids.length)
|
|
126
|
+
record_wait_trace(transition, :waiting)
|
|
127
|
+
WAIT_PENDING
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Enqueues each captured request under its custom id; returns them by id.
|
|
131
|
+
def enqueue_requests(captured)
|
|
132
|
+
ids = captured.requests.to_h { |branch_key, request| [Batch.custom_id(captured.token, branch_key), request] }
|
|
133
|
+
ids.each do |custom_id, request|
|
|
134
|
+
Smith.config.batch_queue.enqueue(custom_id:, await_token: captured.token, request:,
|
|
135
|
+
execution_key: @persistence_key)
|
|
136
|
+
# Handed off, it may be sent: its hold stays until its result, or
|
|
137
|
+
# expires.
|
|
138
|
+
@batch_holds&.delete(custom_id)
|
|
139
|
+
end
|
|
140
|
+
ids
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def pending_batch_record(transition, captured, ids)
|
|
144
|
+
{ "transition" => transition.name.to_s, "attempt" => captured.attempt,
|
|
145
|
+
"key_generation" => captured.key_generation, "requests" => ids.transform_values { Batch.digest(_1) } }.freeze
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|