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
|
@@ -13,61 +13,70 @@ module Smith
|
|
|
13
13
|
|
|
14
14
|
private
|
|
15
15
|
|
|
16
|
+
# A failed attempt's own reported usage, cache counts included, is
|
|
17
|
+
# recorded, priced and settled into the budget like any billed
|
|
18
|
+
# response, so its usage entry agrees with its :provider_call trace.
|
|
16
19
|
def account_failed_attempt(error, model_reference, agent_class, attempt_id: nil)
|
|
17
|
-
|
|
20
|
+
usage = ProviderUsage.from_message(error)
|
|
21
|
+
return unless usage
|
|
18
22
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
return unless input.is_a?(Integer) && output.is_a?(Integer)
|
|
23
|
+
record_billed_usages(agent_class, [usage], :failed_attempt, model_reference, attempt_id:)
|
|
24
|
+
end
|
|
22
25
|
|
|
23
|
-
|
|
26
|
+
# The completed rounds of a tool loop whose attempt then failed were
|
|
27
|
+
# billed: they are recorded and settled into the budget like a failed
|
|
28
|
+
# attempt's usage. Returns the provider usages recorded, so the
|
|
29
|
+
# attempt's trace carries the same totals its usage entries hold.
|
|
30
|
+
def account_completed_prefix(agent_class, model_reference, messages, attempt_id: nil, stream_rounds: nil)
|
|
31
|
+
stream = stream_rounds && { rounds: stream_rounds, output_limit: nil }
|
|
32
|
+
completion = Completion.from_messages(response: nil, messages:, stream:)
|
|
33
|
+
record_billed_usages(agent_class, completion.provider_usages, :partial_attempt, model_reference, attempt_id:)
|
|
34
|
+
completion.provider_usages
|
|
24
35
|
end
|
|
25
36
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
37
|
+
# Usage that is billed but is not the invocation's result joins the
|
|
38
|
+
# failed billable attempts, which budget settlement reads beside it.
|
|
39
|
+
def record_billed_usages(agent_class, usages, attempt_kind, model_reference, attempt_id:)
|
|
40
|
+
results = record_response_usages(agent_class, usages, attempt_kind, model_reference, attempt_id:)
|
|
41
|
+
add_failed_agent_results(results)
|
|
29
42
|
end
|
|
30
43
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
input_tokens:,
|
|
37
|
-
output_tokens:
|
|
38
|
-
)
|
|
39
|
-
agent_result = Workflow::AgentResult.new(
|
|
40
|
-
content: nil,
|
|
41
|
-
input_tokens:,
|
|
42
|
-
output_tokens:,
|
|
43
|
-
cost: cost,
|
|
44
|
-
model_used: model_reference.model_id,
|
|
45
|
-
provider_used: model_reference.provider
|
|
46
|
-
)
|
|
47
|
-
Thread.current[:smith_failed_agent_results] ||= []
|
|
48
|
-
Thread.current[:smith_failed_agent_results] << agent_result
|
|
49
|
-
record_usage(agent_class, agent_result, :failed_attempt, model_reference, attempt_id:)
|
|
44
|
+
# A new Array, never the one in place: a step's thread context
|
|
45
|
+
# (ThreadContextSnapshot) puts back the list it started with, which
|
|
46
|
+
# must not carry this step's failed attempts into the next.
|
|
47
|
+
def add_failed_agent_results(results)
|
|
48
|
+
Thread.current[:smith_failed_agent_results] = [*Array(Thread.current[:smith_failed_agent_results]), *results]
|
|
50
49
|
end
|
|
51
50
|
|
|
52
|
-
|
|
51
|
+
# Records and settles one completed attempt's usage and returns its
|
|
52
|
+
# result, whether or not the result becomes the output.
|
|
53
|
+
def account_attempt(agent_class, completion, model_reference, attempt_id: nil)
|
|
53
54
|
model_reference = coerce_model_reference(model_reference)
|
|
54
|
-
agent_result =
|
|
55
|
-
content: completion.content,
|
|
56
|
-
input_tokens: completion.input_tokens,
|
|
57
|
-
output_tokens: completion.output_tokens,
|
|
58
|
-
cost: nil,
|
|
59
|
-
model_used: model_reference.model_id,
|
|
60
|
-
provider_used: model_reference.provider
|
|
61
|
-
)
|
|
55
|
+
agent_result = completion_agent_result(completion, model_reference)
|
|
62
56
|
Thread.current[:smith_last_agent_result] = agent_result
|
|
63
57
|
emit_token_usage(agent_result)
|
|
64
58
|
account_completion!(agent_class, completion, model_reference, agent_result, attempt_id)
|
|
59
|
+
agent_result
|
|
60
|
+
end
|
|
65
61
|
|
|
66
|
-
|
|
62
|
+
# The output has been through `after_completion` (OutputIntegrity).
|
|
63
|
+
def finalize_output(agent_class, agent_result)
|
|
67
64
|
raise_blank_output!(agent_class, agent_result)
|
|
68
65
|
agent_result
|
|
69
66
|
end
|
|
70
67
|
|
|
68
|
+
def completion_agent_result(completion, model_reference)
|
|
69
|
+
Workflow::AgentResult.new(
|
|
70
|
+
content: completion.content,
|
|
71
|
+
input_tokens: completion.input_tokens,
|
|
72
|
+
output_tokens: completion.output_tokens,
|
|
73
|
+
cost: nil,
|
|
74
|
+
model_used: model_reference.model_id,
|
|
75
|
+
provider_used: model_reference.provider,
|
|
76
|
+
**Workflow::AgentResult.usage_details(completion.usage)
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
71
80
|
def raise_blank_output!(agent_class, agent_result)
|
|
72
81
|
return unless blank_agent_output?(agent_result.content)
|
|
73
82
|
|
|
@@ -90,26 +99,33 @@ module Smith
|
|
|
90
99
|
# budget settlement and result surfaces read) and the recorded entries,
|
|
91
100
|
# the budget ledger, and the :cost trace all agree. The trace emits
|
|
92
101
|
# only for fully metered, fully priced invocations, so a partial
|
|
93
|
-
# figure is never presented as the invocation cost; the partial
|
|
94
|
-
# still
|
|
102
|
+
# figure is never presented as the invocation cost; the partial sums
|
|
103
|
+
# of cost and tokens still settle the budget because they are what
|
|
104
|
+
# was verifiably billed.
|
|
95
105
|
def account_completion!(agent_class, completion, model_reference, agent_result, attempt_id)
|
|
96
|
-
invocation_cost, fully_priced = record_completion_usage(
|
|
106
|
+
invocation_cost, fully_priced, results = record_completion_usage(
|
|
97
107
|
agent_class, completion, :completed_attempt, model_reference, attempt_id:
|
|
98
108
|
)
|
|
99
109
|
agent_result.cost = invocation_cost
|
|
100
|
-
|
|
110
|
+
agent_result.metered_tokens = results.sum(&:total_tokens) unless completion.usage_complete
|
|
111
|
+
return unless fully_priced
|
|
112
|
+
|
|
113
|
+
emit_cost_trace(agent_result, invocation_cost, cache_unpriced: results.any?(&:cache_unpriced))
|
|
101
114
|
end
|
|
102
115
|
|
|
116
|
+
# Prices the result and notes whether cache tokens were priced at 0
|
|
117
|
+
# for lack of a rate, which the :cost trace reports.
|
|
103
118
|
def compute_agent_cost(agent_result)
|
|
104
119
|
return unless agent_result.usage_known?
|
|
105
120
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
)
|
|
121
|
+
usage = {
|
|
122
|
+
model: agent_result.model_used, provider: agent_result.provider_used,
|
|
123
|
+
input_tokens: agent_result.input_tokens, output_tokens: agent_result.output_tokens,
|
|
124
|
+
cache_read_tokens: agent_result.cache_read_tokens || 0,
|
|
125
|
+
cache_write_tokens: agent_result.cache_write_tokens || 0
|
|
126
|
+
}
|
|
127
|
+
agent_result.cost = Smith::Pricing.compute_cost(**usage, mode: agent_result.mode || :sync)
|
|
128
|
+
agent_result.cache_unpriced = Smith::Pricing.cache_unpriced?(**usage.except(:output_tokens))
|
|
113
129
|
end
|
|
114
130
|
end
|
|
115
131
|
end
|
data/lib/smith/agent.rb
CHANGED
|
@@ -11,9 +11,10 @@ module Smith
|
|
|
11
11
|
EXECUTION_IDENTITY_UNSET = Object.new.freeze
|
|
12
12
|
TOOL_BUDGET_EXHAUSTION_UNSET = Object.new.freeze
|
|
13
13
|
TOOL_BUDGET_EXHAUSTION_POLICIES = %i[raise complete].freeze
|
|
14
|
+
BUDGET_KEYS = %i[token_limit cost wall_clock tool_calls total_tokens total_cost].freeze
|
|
14
15
|
|
|
15
16
|
private_constant :EXECUTION_IDENTITY_UNSET
|
|
16
|
-
private_constant :TOOL_BUDGET_EXHAUSTION_UNSET, :TOOL_BUDGET_EXHAUSTION_POLICIES
|
|
17
|
+
private_constant :TOOL_BUDGET_EXHAUSTION_UNSET, :TOOL_BUDGET_EXHAUSTION_POLICIES, :BUDGET_KEYS
|
|
17
18
|
|
|
18
19
|
# Reserved input names auto-injected by the normalizer into
|
|
19
20
|
# runtime_context. User-side `inputs :name` calls cannot redeclare
|
|
@@ -30,8 +31,12 @@ module Smith
|
|
|
30
31
|
subclass.instance_variable_set(:@output_schema_class, @output_schema_class)
|
|
31
32
|
subclass.instance_variable_set(:@data_volume, @data_volume)
|
|
32
33
|
subclass.instance_variable_set(:@fallback_models_list, @fallback_models_list&.dup&.freeze)
|
|
34
|
+
subclass.instance_variable_set(:@fallback_models_block, @fallback_models_block)
|
|
33
35
|
subclass.instance_variable_set(:@model_block, @model_block)
|
|
34
36
|
subclass.instance_variable_set(:@tool_budget_exhaustion, @tool_budget_exhaustion)
|
|
37
|
+
%i[@on_truncation @on_refusal @output_contract @output_repairs].each do |name|
|
|
38
|
+
subclass.instance_variable_set(name, instance_variable_get(name))
|
|
39
|
+
end
|
|
35
40
|
subclass.instance_variable_set(:@execution_identity, nil)
|
|
36
41
|
subclass.instance_variable_set(:@registered_name, nil)
|
|
37
42
|
end
|
|
@@ -48,6 +53,12 @@ module Smith
|
|
|
48
53
|
def budget(**opts)
|
|
49
54
|
return @budget_config if opts.empty?
|
|
50
55
|
|
|
56
|
+
unknown = opts.keys - BUDGET_KEYS
|
|
57
|
+
unless unknown.empty?
|
|
58
|
+
raise ArgumentError, "agent budget does not accept #{unknown.map(&:inspect).join(", ")}; " \
|
|
59
|
+
"accepted keys are #{BUDGET_KEYS.map(&:inspect).join(", ")}"
|
|
60
|
+
end
|
|
61
|
+
|
|
51
62
|
@budget_config = opts
|
|
52
63
|
end
|
|
53
64
|
|
|
@@ -140,8 +151,10 @@ require_relative "agent/dynamic_configuration"
|
|
|
140
151
|
require_relative "agent/fallback_configuration"
|
|
141
152
|
require_relative "agent/reserved_input_bridge"
|
|
142
153
|
require_relative "agent/chat_construction"
|
|
154
|
+
require_relative "agent/output_policy"
|
|
143
155
|
|
|
144
156
|
Smith::Agent.extend(Smith::Agent::DynamicConfiguration)
|
|
145
157
|
Smith::Agent.extend(Smith::Agent::FallbackConfiguration)
|
|
146
158
|
Smith::Agent.extend(Smith::Agent::ReservedInputBridge)
|
|
147
159
|
Smith::Agent.extend(Smith::Agent::ChatConstruction)
|
|
160
|
+
Smith::Agent.extend(Smith::Agent::OutputPolicy)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
require_relative "store_rules"
|
|
6
|
+
|
|
7
|
+
module Smith
|
|
8
|
+
module Artifacts
|
|
9
|
+
# An artifact store in a database table, through the host's Active
|
|
10
|
+
# Record model (a class or its name):
|
|
11
|
+
#
|
|
12
|
+
# create_table :smith_artifacts do |t|
|
|
13
|
+
# t.string :ref, null: false, index: { unique: true }
|
|
14
|
+
# t.binary :data, null: false, limit: 64.megabytes # MySQL's blob holds 64 KiB
|
|
15
|
+
# t.string :content_type, null: false
|
|
16
|
+
# t.string :execution_namespace, index: true
|
|
17
|
+
# t.timestamps
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# Each artifact is one row, its ref a random UUID (after the store's
|
|
21
|
+
# tenant `namespace:` when one is set), with the run that stored it, so
|
|
22
|
+
# the store can say which run stored a ref.
|
|
23
|
+
class ActiveRecordStore
|
|
24
|
+
include StoreRules
|
|
25
|
+
|
|
26
|
+
INNER_REF = StoreRules::UUID
|
|
27
|
+
|
|
28
|
+
def initialize(model:, namespace: nil)
|
|
29
|
+
@model_source = model.is_a?(String) ? model.dup.freeze : model
|
|
30
|
+
@namespace = check_namespace!(namespace)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def store(data, content_type: "application/octet-stream", execution_namespace: nil)
|
|
34
|
+
enforce_tenant_isolation!
|
|
35
|
+
check_data!(data)
|
|
36
|
+
ref = generate_ref(SecureRandom.uuid)
|
|
37
|
+
model_class.create!(ref:, data:, content_type:, execution_namespace:)
|
|
38
|
+
ref
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def fetch(ref)
|
|
42
|
+
enforce_tenant_isolation!
|
|
43
|
+
model_class.find_by(ref:)&.data if owns_ref?(ref)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Whether a run (its execution namespace) stored this ref.
|
|
47
|
+
def stored_by?(ref, execution_namespace)
|
|
48
|
+
!execution_namespace.nil? && owns_ref?(ref) && model_class.where(ref:, execution_namespace:).exists?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def model = model_class
|
|
52
|
+
|
|
53
|
+
def expired(retention: nil, execution_namespace: nil)
|
|
54
|
+
retention = effective_retention(retention)
|
|
55
|
+
return [] unless retention
|
|
56
|
+
|
|
57
|
+
scope = model_class.where(model_class.arel_table[:created_at].lt(Time.now.utc - retention))
|
|
58
|
+
scope = scope.where(execution_namespace:) if execution_namespace
|
|
59
|
+
scope.pluck(:ref).select { |ref| owns_ref?(ref) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Removes the artifact (when a run is named, only if that run stored
|
|
63
|
+
# it).
|
|
64
|
+
def delete(ref, execution_namespace: nil)
|
|
65
|
+
enforce_tenant_isolation!
|
|
66
|
+
return unless owns_ref?(ref)
|
|
67
|
+
|
|
68
|
+
scope = model_class.where(ref:)
|
|
69
|
+
scope = scope.where(execution_namespace:) if execution_namespace
|
|
70
|
+
scope.delete_all
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def model_class = @model_source.is_a?(String) ? Object.const_get(@model_source) : @model_source
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
data/lib/smith/artifacts/file.rb
CHANGED
|
@@ -1,38 +1,71 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
3
|
+
require "fileutils"
|
|
4
4
|
require "json"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
require_relative "store_rules"
|
|
5
8
|
|
|
6
9
|
module Smith
|
|
7
10
|
module Artifacts
|
|
11
|
+
# An artifact store in a directory (`dir:`), shared only when the
|
|
12
|
+
# directory is. Each artifact is a file named by a random UUID (after
|
|
13
|
+
# the store's tenant `namespace:` when one is set) beside a `.meta`
|
|
14
|
+
# file with its type and the run that stored it; no ref names any other
|
|
15
|
+
# file.
|
|
8
16
|
class File
|
|
17
|
+
include StoreRules
|
|
18
|
+
|
|
19
|
+
INNER_REF = StoreRules::UUID
|
|
20
|
+
|
|
9
21
|
def initialize(dir:, namespace: nil)
|
|
10
22
|
@dir = dir
|
|
11
|
-
@namespace = namespace
|
|
23
|
+
@namespace = check_namespace!(namespace)
|
|
12
24
|
end
|
|
13
25
|
|
|
14
26
|
def store(data, content_type: "application/octet-stream", execution_namespace: nil)
|
|
15
|
-
|
|
27
|
+
enforce_tenant_isolation!
|
|
28
|
+
check_data!(data)
|
|
29
|
+
ref = generate_ref(SecureRandom.uuid)
|
|
16
30
|
meta = { content_type: content_type }
|
|
17
31
|
meta[:execution_namespace] = execution_namespace if execution_namespace
|
|
18
|
-
::File.
|
|
32
|
+
::File.binwrite(::File.join(@dir, ref), data)
|
|
19
33
|
::File.write(::File.join(@dir, "#{ref}.meta"), JSON.generate(meta))
|
|
20
34
|
ref
|
|
21
35
|
end
|
|
22
36
|
|
|
23
37
|
def fetch(ref)
|
|
38
|
+
enforce_tenant_isolation!
|
|
39
|
+
return unless owns_ref?(ref)
|
|
40
|
+
|
|
24
41
|
path = ::File.join(@dir, ref)
|
|
25
|
-
::File.
|
|
42
|
+
::File.binread(path) if ::File.file?(path)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Removes the artifact (when a run is named, only if that run stored
|
|
46
|
+
# it).
|
|
47
|
+
def delete(ref, execution_namespace: nil)
|
|
48
|
+
enforce_tenant_isolation!
|
|
49
|
+
return unless owns_ref?(ref) && (execution_namespace.nil? || stored_by?(ref, execution_namespace))
|
|
50
|
+
|
|
51
|
+
[ref, "#{ref}.meta"].each { |name| ::FileUtils.rm_f(::File.join(@dir, name)) }
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Whether a run (its execution namespace) stored this ref.
|
|
56
|
+
def stored_by?(ref, execution_namespace)
|
|
57
|
+
!execution_namespace.nil? && owns_ref?(ref) && stored_namespace(ref) == execution_namespace
|
|
26
58
|
end
|
|
27
59
|
|
|
28
60
|
def expired(retention: nil, execution_namespace: nil)
|
|
61
|
+
retention = effective_retention(retention)
|
|
29
62
|
return [] unless retention
|
|
30
63
|
|
|
31
64
|
cutoff = Time.now.utc - retention
|
|
32
|
-
Dir.glob(::File.join(@dir, "*")).
|
|
65
|
+
Dir.glob(::File.join(@dir, "*")).filter_map do |path|
|
|
33
66
|
ref = ::File.basename(path)
|
|
34
|
-
next unless ::File.mtime(path).utc < cutoff
|
|
35
|
-
next if execution_namespace &&
|
|
67
|
+
next unless owns_ref?(ref) && ::File.mtime(path).utc < cutoff
|
|
68
|
+
next if execution_namespace && stored_namespace(ref) != execution_namespace
|
|
36
69
|
|
|
37
70
|
ref
|
|
38
71
|
end
|
|
@@ -40,19 +73,13 @@ module Smith
|
|
|
40
73
|
|
|
41
74
|
private
|
|
42
75
|
|
|
43
|
-
def
|
|
44
|
-
raw = SecureRandom.uuid
|
|
45
|
-
@namespace ? "#{@namespace}:#{raw}" : raw
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def matches_execution_namespace?(ref, execution_namespace)
|
|
76
|
+
def stored_namespace(ref)
|
|
49
77
|
meta_path = ::File.join(@dir, "#{ref}.meta")
|
|
50
|
-
return
|
|
78
|
+
return unless ::File.exist?(meta_path)
|
|
51
79
|
|
|
52
|
-
|
|
53
|
-
meta[:execution_namespace] == execution_namespace
|
|
80
|
+
JSON.parse(::File.read(meta_path))["execution_namespace"]
|
|
54
81
|
rescue JSON::ParserError
|
|
55
|
-
|
|
82
|
+
nil
|
|
56
83
|
end
|
|
57
84
|
end
|
|
58
85
|
end
|
|
@@ -2,73 +2,84 @@
|
|
|
2
2
|
|
|
3
3
|
require "digest"
|
|
4
4
|
|
|
5
|
+
require_relative "store_rules"
|
|
6
|
+
|
|
5
7
|
module Smith
|
|
6
8
|
module Artifacts
|
|
9
|
+
# An artifact store in the process: for one process and for tests.
|
|
10
|
+
# Refs are content hashes, so runs that store the same bytes share one;
|
|
11
|
+
# each run's claim on it (and when it made the claim) is kept apart, so
|
|
12
|
+
# a run's delete drops only its own claim, and the bytes go with the
|
|
13
|
+
# last claim.
|
|
7
14
|
class Memory
|
|
15
|
+
include StoreRules
|
|
16
|
+
|
|
17
|
+
INNER_REF = /\A\h{64}\z/
|
|
18
|
+
HOST = :host
|
|
19
|
+
private_constant :HOST
|
|
20
|
+
|
|
8
21
|
attr_reader :namespace
|
|
9
22
|
|
|
10
23
|
def initialize(namespace: nil)
|
|
11
|
-
@namespace = namespace
|
|
24
|
+
@namespace = check_namespace!(namespace)
|
|
12
25
|
@store = {}
|
|
13
26
|
@metadata = {}
|
|
14
27
|
end
|
|
15
28
|
|
|
16
29
|
def store(data, content_type: "application/octet-stream", execution_namespace: nil)
|
|
17
30
|
enforce_tenant_isolation!
|
|
18
|
-
|
|
31
|
+
check_data!(data)
|
|
32
|
+
ref = generate_ref(Digest::SHA256.hexdigest(data))
|
|
19
33
|
@store[ref] = data
|
|
20
|
-
@metadata[ref] ||= { content_type
|
|
21
|
-
|
|
34
|
+
meta = (@metadata[ref] ||= { content_type:, claims: {} })
|
|
35
|
+
meta[:claims][execution_namespace || HOST] = Time.now.utc
|
|
22
36
|
ref
|
|
23
37
|
end
|
|
24
38
|
|
|
25
39
|
def fetch(ref)
|
|
26
40
|
enforce_tenant_isolation!
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
@store[ref]
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def expired(retention: nil, execution_namespace: nil)
|
|
33
|
-
effective_retention = retention || Smith.config.artifact_retention
|
|
34
|
-
return [] unless effective_retention
|
|
35
|
-
|
|
36
|
-
cutoff = Time.now.utc - effective_retention
|
|
37
|
-
@metadata.select { |ref, meta| expired_match?(ref, meta, cutoff, execution_namespace) }.keys
|
|
41
|
+
@store[ref] if owns_ref?(ref)
|
|
38
42
|
end
|
|
39
43
|
|
|
40
|
-
|
|
44
|
+
# Drops a run's claim (or, with no run named, every claim); the bytes
|
|
45
|
+
# go when no claim is left.
|
|
46
|
+
def delete(ref, execution_namespace: nil)
|
|
47
|
+
enforce_tenant_isolation!
|
|
48
|
+
meta = @metadata[ref] if owns_ref?(ref)
|
|
49
|
+
return unless meta
|
|
41
50
|
|
|
42
|
-
|
|
43
|
-
return unless
|
|
51
|
+
execution_namespace ? meta[:claims].delete(execution_namespace) : meta[:claims].clear
|
|
52
|
+
return unless meta[:claims].empty?
|
|
44
53
|
|
|
45
|
-
|
|
46
|
-
|
|
54
|
+
@store.delete(ref)
|
|
55
|
+
@metadata.delete(ref)
|
|
56
|
+
nil
|
|
47
57
|
end
|
|
48
58
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
(execution_namespace.nil? || meta[:execution_namespaces]&.include?(execution_namespace))
|
|
53
|
-
end
|
|
59
|
+
# Whether a run (its execution namespace) stored this ref.
|
|
60
|
+
def stored_by?(ref, execution_namespace)
|
|
61
|
+
return false if execution_namespace.nil? || !owns_ref?(ref)
|
|
54
62
|
|
|
55
|
-
|
|
56
|
-
content_hash = Digest::SHA256.hexdigest(data.to_s)
|
|
57
|
-
@namespace ? "#{@namespace}:#{content_hash}" : content_hash
|
|
63
|
+
@metadata.dig(ref, :claims)&.key?(execution_namespace) || false
|
|
58
64
|
end
|
|
59
65
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
66
|
+
# Refs whose claims (or the run's claim, when one is named) are all
|
|
67
|
+
# older than the retention.
|
|
68
|
+
def expired(retention: nil, execution_namespace: nil)
|
|
69
|
+
retention = effective_retention(retention)
|
|
70
|
+
return [] unless retention
|
|
71
|
+
|
|
72
|
+
cutoff = Time.now.utc - retention
|
|
73
|
+
@metadata.filter_map do |ref, meta|
|
|
74
|
+
ref if owns_ref?(ref) && claims_before?(meta[:claims], execution_namespace, cutoff)
|
|
65
75
|
end
|
|
66
76
|
end
|
|
67
77
|
|
|
68
|
-
|
|
69
|
-
return unless Smith.config.artifact_tenant_isolation
|
|
78
|
+
private
|
|
70
79
|
|
|
71
|
-
|
|
80
|
+
def claims_before?(claims, execution_namespace, cutoff)
|
|
81
|
+
claims = claims.slice(execution_namespace) if execution_namespace
|
|
82
|
+
!claims.empty? && claims.values.all? { |stored_at| stored_at < cutoff }
|
|
72
83
|
end
|
|
73
84
|
end
|
|
74
85
|
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
require_relative "store_rules"
|
|
6
|
+
|
|
7
|
+
module Smith
|
|
8
|
+
module Artifacts
|
|
9
|
+
# An artifact store in an S3-compatible bucket: AWS S3, Cloudflare R2
|
|
10
|
+
# (`endpoint: "https://<account>.r2.cloudflarestorage.com"`,
|
|
11
|
+
# `region: "auto"`), MinIO and the like. Each artifact is one object
|
|
12
|
+
# under `prefix`, its ref a random UUID (after the store's tenant
|
|
13
|
+
# `namespace:` when one is set), and the run that stored it is kept in
|
|
14
|
+
# the object's metadata, so the store can say which run stored a ref.
|
|
15
|
+
#
|
|
16
|
+
# The client is the host's `Aws::S3::Client` (`client:`), or one built
|
|
17
|
+
# from the remaining options (`region:`, `endpoint:`,
|
|
18
|
+
# `access_key_id:`, `secret_access_key:`, ...), which needs the
|
|
19
|
+
# `aws-sdk-s3` gem in the host's bundle; Smith does not depend on it.
|
|
20
|
+
# Only keys of the store's ref shape under the prefix are artifacts, so
|
|
21
|
+
# other objects in the bucket are neither read, listed nor deleted.
|
|
22
|
+
class S3
|
|
23
|
+
include StoreRules
|
|
24
|
+
|
|
25
|
+
INNER_REF = StoreRules::UUID
|
|
26
|
+
NAMESPACE_KEY = "smith-execution-namespace"
|
|
27
|
+
|
|
28
|
+
def initialize(bucket:, prefix: "smith/artifacts", namespace: nil, client: nil, **client_options)
|
|
29
|
+
if client && client_options.any?
|
|
30
|
+
raise ArgumentError, "give Smith::Artifacts::S3 a client: or the options to build one, not both"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
@bucket = bucket
|
|
34
|
+
@prefix = prefix.to_s.delete_suffix("/")
|
|
35
|
+
@namespace = check_namespace!(namespace)
|
|
36
|
+
@client = client || self.class.build_client(**client_options)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.build_client(**)
|
|
40
|
+
require "aws-sdk-s3"
|
|
41
|
+
::Aws::S3::Client.new(**)
|
|
42
|
+
rescue LoadError
|
|
43
|
+
raise Smith::Error, "Smith::Artifacts::S3 needs the aws-sdk-s3 gem in the host's bundle, or a client:"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def store(data, content_type: "application/octet-stream", execution_namespace: nil)
|
|
47
|
+
enforce_tenant_isolation!
|
|
48
|
+
check_data!(data)
|
|
49
|
+
ref = generate_ref(SecureRandom.uuid)
|
|
50
|
+
metadata = execution_namespace ? { NAMESPACE_KEY => execution_namespace } : {}
|
|
51
|
+
@client.put_object(bucket: @bucket, key: object_key(ref), body: data, content_type:, metadata:)
|
|
52
|
+
ref
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def fetch(ref)
|
|
56
|
+
enforce_tenant_isolation!
|
|
57
|
+
return unless owns_ref?(ref)
|
|
58
|
+
|
|
59
|
+
@client.get_object(bucket: @bucket, key: object_key(ref)).body.read
|
|
60
|
+
rescue StandardError => e
|
|
61
|
+
raise unless missing?(e)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Whether a run (its execution namespace) stored this ref.
|
|
65
|
+
def stored_by?(ref, execution_namespace)
|
|
66
|
+
return false if execution_namespace.nil? || !owns_ref?(ref)
|
|
67
|
+
|
|
68
|
+
@client.head_object(bucket: @bucket, key: object_key(ref)).metadata[NAMESPACE_KEY] == execution_namespace
|
|
69
|
+
rescue StandardError => e
|
|
70
|
+
raise unless missing?(e)
|
|
71
|
+
|
|
72
|
+
false
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Listing the prefix, then (for one run's refs) a HEAD per expired
|
|
76
|
+
# object.
|
|
77
|
+
def expired(retention: nil, execution_namespace: nil)
|
|
78
|
+
retention = effective_retention(retention)
|
|
79
|
+
return [] unless retention
|
|
80
|
+
|
|
81
|
+
cutoff = Time.now.utc - retention
|
|
82
|
+
objects.filter_map do |object|
|
|
83
|
+
ref = object.key.delete_prefix(key_prefix)
|
|
84
|
+
next unless object.last_modified.utc < cutoff && owns_ref?(ref)
|
|
85
|
+
next if execution_namespace && !stored_by?(ref, execution_namespace)
|
|
86
|
+
|
|
87
|
+
ref
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Removes the artifact (when a run is named, only if that run stored
|
|
92
|
+
# it).
|
|
93
|
+
def delete(ref, execution_namespace: nil)
|
|
94
|
+
enforce_tenant_isolation!
|
|
95
|
+
return unless owns_ref?(ref) && (execution_namespace.nil? || stored_by?(ref, execution_namespace))
|
|
96
|
+
|
|
97
|
+
@client.delete_object(bucket: @bucket, key: object_key(ref))
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def key_prefix = @prefix.empty? ? "" : "#{@prefix}/"
|
|
104
|
+
|
|
105
|
+
def object_key(ref) = "#{key_prefix}#{ref}"
|
|
106
|
+
|
|
107
|
+
# Every object under the prefix, page by page.
|
|
108
|
+
def objects
|
|
109
|
+
Enumerator.new do |yielder|
|
|
110
|
+
token = nil
|
|
111
|
+
loop do
|
|
112
|
+
page = @client.list_objects_v2(bucket: @bucket, prefix: key_prefix, continuation_token: token)
|
|
113
|
+
page.contents.each { |object| yielder << object }
|
|
114
|
+
break unless page.is_truncated
|
|
115
|
+
|
|
116
|
+
token = page.next_continuation_token
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The SDK's missing-object errors (NoSuchKey on a read, NotFound on a
|
|
122
|
+
# head), by name, so Smith loads no SDK class.
|
|
123
|
+
def missing?(error) = error.class.name.to_s.end_with?("::NoSuchKey", "::NotFound")
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|