smith-agents 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +698 -0
- data/README.md +621 -15
- data/UPSTREAM_PROPOSAL.md +1 -0
- data/docs/CONFIGURATION.md +80 -14
- data/docs/PATTERNS.md +30 -13
- data/docs/PERSISTENCE.md +733 -5
- data/docs/TOOLS_AND_GUARDRAILS.md +24 -3
- data/lib/generators/smith/install/install_generator.rb +63 -2
- data/lib/generators/smith/install/templates/create_workflow_states.rb.tt +31 -0
- data/lib/generators/smith/install/templates/smith.rb.tt +39 -8
- data/lib/generators/smith/install/templates/workflow_state.rb.tt +19 -0
- data/lib/smith/agent/completion.rb +32 -6
- data/lib/smith/agent/completion_usage_recording.rb +23 -14
- data/lib/smith/agent/content_bytes.rb +98 -0
- data/lib/smith/agent/content_parts.rb +149 -0
- data/lib/smith/agent/invocation_preparation.rb +37 -14
- data/lib/smith/agent/lifecycle.rb +16 -5
- data/lib/smith/agent/observed_model.rb +29 -0
- data/lib/smith/agent/output_contract_check.rb +53 -0
- data/lib/smith/agent/output_integrity.rb +137 -0
- data/lib/smith/agent/output_policy.rb +92 -0
- data/lib/smith/agent/output_recovery.rb +129 -0
- data/lib/smith/agent/provider_attempt.rb +6 -0
- data/lib/smith/agent/provider_call_timing.rb +13 -4
- data/lib/smith/agent/provider_completion.rb +35 -32
- data/lib/smith/agent/provider_failure_handling.rb +5 -1
- data/lib/smith/agent/provider_request.rb +36 -0
- data/lib/smith/agent/provider_streaming.rb +155 -0
- data/lib/smith/agent/provider_usage.rb +70 -3
- data/lib/smith/agent/usage_entry_recording.rb +4 -2
- data/lib/smith/agent/usage_traces.rb +9 -2
- data/lib/smith/agent/usage_tracking.rb +64 -51
- data/lib/smith/agent.rb +5 -0
- data/lib/smith/artifacts/active_record_store.rb +79 -0
- data/lib/smith/artifacts/file.rb +45 -18
- data/lib/smith/artifacts/memory.rb +47 -36
- data/lib/smith/artifacts/s3.rb +126 -0
- data/lib/smith/artifacts/scoped_store.rb +55 -7
- data/lib/smith/artifacts/store_rules.rb +49 -0
- data/lib/smith/artifacts.rb +98 -1
- data/lib/smith/batch/message_shape.rb +36 -0
- data/lib/smith/batch.rb +166 -0
- data/lib/smith/budget/amount.rb +25 -0
- data/lib/smith/budget/continuation.rb +153 -0
- data/lib/smith/budget/host_ledger.rb +137 -0
- data/lib/smith/budget/ledger.rb +4 -2
- data/lib/smith/budget/ledger_state_transition.rb +7 -5
- data/lib/smith/budget/request_hold.rb +211 -0
- data/lib/smith/budget/spent_reservation.rb +18 -0
- data/lib/smith/child_run.rb +41 -0
- data/lib/smith/child_runs_failed.rb +56 -0
- data/lib/smith/content.rb +96 -0
- data/lib/smith/context/session.rb +14 -2
- data/lib/smith/context/state_injection.rb +10 -5
- data/lib/smith/context.rb +28 -0
- data/lib/smith/doctor/checks/artifacts.rb +103 -0
- data/lib/smith/doctor/checks/configuration.rb +0 -11
- data/lib/smith/doctor/checks/durability.rb +20 -18
- data/lib/smith/doctor/checks/state_model.rb +63 -0
- data/lib/smith/doctor/installer.rb +9 -2
- data/lib/smith/doctor.rb +4 -0
- data/lib/smith/errors.rb +249 -2
- data/lib/smith/idempotency_key/canonical.rb +74 -0
- data/lib/smith/idempotency_key.rb +145 -0
- data/lib/smith/launchers/inline.rb +46 -0
- data/lib/smith/message.rb +76 -0
- data/lib/smith/models/tool_routing.rb +47 -44
- data/lib/smith/output_failure.rb +130 -0
- data/lib/smith/persistence_adapters/active_record_exact_store.rb +43 -0
- data/lib/smith/pricing/catalog_validation.rb +91 -0
- data/lib/smith/pricing.rb +95 -64
- data/lib/smith/providers/anthropic/batches.rb +116 -0
- data/lib/smith/providers/batch_exchange.rb +77 -0
- data/lib/smith/providers/openai/responses.rb +45 -20
- data/lib/smith/providers/output_limit.rb +68 -0
- data/lib/smith/providers/request_holds.rb +139 -0
- data/lib/smith/providers/response_facts.rb +108 -0
- data/lib/smith/providers/schema_constraint_check.rb +141 -0
- data/lib/smith/providers/schema_constraints.rb +87 -0
- data/lib/smith/providers/schema_rules.rb +66 -0
- data/lib/smith/providers/stop_reason.rb +39 -0
- data/lib/smith/providers/stream_facts.rb +192 -0
- data/lib/smith/response_memo.rb +152 -0
- data/lib/smith/setting_constructors.rb +47 -0
- data/lib/smith/streaming/json_field.rb +136 -0
- data/lib/smith/streaming/json_string.rb +91 -0
- data/lib/smith/streaming/session.rb +77 -0
- data/lib/smith/streaming/think_tags.rb +67 -0
- data/lib/smith/streaming.rb +64 -0
- data/lib/smith/testing/checks.rb +58 -0
- data/lib/smith/testing/hooks.rb +85 -0
- data/lib/smith/testing/minitest.rb +37 -0
- data/lib/smith/testing/rspec.rb +52 -0
- data/lib/smith/testing/stub.rb +110 -0
- data/lib/smith/testing.rb +158 -0
- data/lib/smith/tool/scoped_context.rb +9 -12
- data/lib/smith/tool.rb +14 -0
- data/lib/smith/trace/logger.rb +1 -1
- data/lib/smith/trace/memory.rb +1 -1
- data/lib/smith/trace/open_telemetry.rb +1 -1
- data/lib/smith/trace.rb +11 -4
- data/lib/smith/untrusted.rb +74 -0
- data/lib/smith/version.rb +2 -2
- data/lib/smith/workflow/active_clock.rb +61 -0
- data/lib/smith/workflow/agent_result.rb +37 -0
- data/lib/smith/workflow/artifact_integration.rb +57 -7
- data/lib/smith/workflow/await_step.rb +42 -0
- data/lib/smith/workflow/batch_charges.rb +74 -0
- data/lib/smith/workflow/batch_execution.rb +149 -0
- data/lib/smith/workflow/batch_holds.rb +73 -0
- data/lib/smith/workflow/batch_wait.rb +134 -0
- data/lib/smith/workflow/budget_integration.rb +11 -5
- data/lib/smith/workflow/budget_pause.rb +51 -0
- data/lib/smith/workflow/child_run_declarations.rb +127 -0
- data/lib/smith/workflow/child_run_execution.rb +150 -0
- data/lib/smith/workflow/child_run_results.rb +55 -0
- data/lib/smith/workflow/composite/effects.rb +37 -6
- data/lib/smith/workflow/composite/error.rb +1 -1
- data/lib/smith/workflow/composite/error_evidence.rb +9 -9
- data/lib/smith/workflow/composite/input.rb +11 -4
- data/lib/smith/workflow/context_keys.rb +77 -0
- data/lib/smith/workflow/deadline_enforcement.rb +1 -1
- data/lib/smith/workflow/deterministic_execution.rb +61 -10
- data/lib/smith/workflow/deterministic_step.rb +55 -6
- data/lib/smith/workflow/dsl.rb +59 -0
- data/lib/smith/workflow/durability.rb +139 -30
- data/lib/smith/workflow/evaluator_optimizer.rb +12 -6
- data/lib/smith/workflow/event_integration.rb +9 -0
- data/lib/smith/workflow/execution.rb +30 -23
- data/lib/smith/workflow/execution_binding_resolution.rb +13 -0
- data/lib/smith/workflow/failure_reconstructor.rb +27 -4
- data/lib/smith/workflow/failure_record.rb +6 -1
- data/lib/smith/workflow/failure_record_validator.rb +11 -4
- data/lib/smith/workflow/fanout_execution.rb +5 -8
- data/lib/smith/workflow/graph/batch_diagnostic.rb +83 -0
- data/lib/smith/workflow/graph/branch_limit_diagnostic.rb +41 -0
- data/lib/smith/workflow/graph/budget_ledger_diagnostic.rb +126 -0
- data/lib/smith/workflow/graph/durable_retry_diagnostic.rb +50 -0
- data/lib/smith/workflow/graph/execution_successors.rb +16 -2
- data/lib/smith/workflow/graph/inherited_untrusted_diagnostics.rb +77 -0
- data/lib/smith/workflow/graph/isolated_input_diagnostic.rb +50 -0
- data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +43 -4
- data/lib/smith/workflow/graph/output_key_diagnostic.rb +53 -0
- data/lib/smith/workflow/graph/output_schema_diagnostic.rb +49 -0
- data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +1 -0
- data/lib/smith/workflow/graph/run_launcher_diagnostic.rb +30 -0
- data/lib/smith/workflow/graph/runtime_binding_diagnostic_builder.rb +9 -3
- data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +7 -3
- data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +8 -1
- data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +9 -2
- data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +7 -4
- data/lib/smith/workflow/graph/stream_diagnostic.rb +95 -0
- data/lib/smith/workflow/graph/transition_contract.rb +19 -0
- data/lib/smith/workflow/graph/transition_contract_attributes.rb +25 -2
- data/lib/smith/workflow/graph/transition_contract_configurations.rb +24 -3
- data/lib/smith/workflow/graph/transition_diagnostics.rb +11 -24
- data/lib/smith/workflow/graph/transition_snapshot.rb +40 -39
- data/lib/smith/workflow/graph/untrusted_input_diagnostic.rb +57 -0
- data/lib/smith/workflow/graph/waiting_child_diagnostic.rb +24 -0
- data/lib/smith/workflow/graph.rb +12 -0
- data/lib/smith/workflow/guarded_step_execution.rb +48 -3
- data/lib/smith/workflow/message_batch.rb +6 -0
- data/lib/smith/workflow/message_roles.rb +25 -0
- data/lib/smith/workflow/message_value_normalizer.rb +2 -1
- data/lib/smith/workflow/nested_execution.rb +47 -4
- data/lib/smith/workflow/orchestrator_worker.rb +9 -3
- data/lib/smith/workflow/parallel_execution.rb +10 -0
- data/lib/smith/workflow/persistence.rb +136 -13
- data/lib/smith/workflow/remembered_calls.rb +92 -0
- data/lib/smith/workflow/retry_execution.rb +68 -9
- data/lib/smith/workflow/retry_wait.rb +134 -0
- data/lib/smith/workflow/split_step_persistence/checkpoint.rb +3 -0
- data/lib/smith/workflow/split_step_persistence/checkpoint_state.rb +7 -0
- data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +4 -1
- data/lib/smith/workflow/split_step_persistence/composite_preparation.rb +3 -1
- data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +1 -0
- data/lib/smith/workflow/split_step_persistence/preparation.rb +5 -0
- data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +1 -0
- data/lib/smith/workflow/step_completion.rb +36 -7
- data/lib/smith/workflow/step_context.rb +21 -0
- data/lib/smith/workflow/step_io.rb +132 -0
- data/lib/smith/workflow/step_stream.rb +52 -0
- data/lib/smith/workflow/thread_context_snapshot.rb +2 -0
- data/lib/smith/workflow/transition.rb +267 -13
- data/lib/smith/workflow/transition_actionability.rb +2 -0
- data/lib/smith/workflow/usage_entry.rb +24 -3
- data/lib/smith/workflow/wait_completion.rb +131 -0
- data/lib/smith/workflow/wait_execution.rb +180 -0
- data/lib/smith/workflow/wait_record.rb +157 -0
- data/lib/smith/workflow.rb +113 -17
- data/lib/smith.rb +101 -45
- metadata +88 -1
|
@@ -20,15 +20,17 @@ module Smith
|
|
|
20
20
|
# Required keys are the pre-attribution UsageEntry shape, so effects
|
|
21
21
|
# produced by an older Smith (a mid-deploy branch worker or a
|
|
22
22
|
# restored checkpoint) stay valid; allowed keys are the current
|
|
23
|
-
# member set, so unknown keys still reject.
|
|
24
|
-
# (
|
|
25
|
-
# construction.
|
|
23
|
+
# member set, so unknown keys still reject. UsageEntry::OPTIONAL_MEMBERS
|
|
24
|
+
# (the attribution members, the cache and thinking counts, the
|
|
25
|
+
# response facts and mode) are optional by construction.
|
|
26
26
|
USAGE_ALLOWED_ATTRIBUTES = Workflow::UsageEntry.members.map(&:to_s).freeze
|
|
27
27
|
USAGE_REQUIRED_ATTRIBUTES =
|
|
28
|
-
(USAGE_ALLOWED_ATTRIBUTES -
|
|
28
|
+
(USAGE_ALLOWED_ATTRIBUTES - Workflow::UsageEntry::OPTIONAL_MEMBERS.map(&:to_s)).freeze
|
|
29
|
+
USAGE_COUNT_ATTRIBUTES = %w[cache_read_tokens cache_write_tokens thinking_tokens].freeze
|
|
30
|
+
USAGE_MODES = %w[sync stream batch].freeze
|
|
29
31
|
TOOL_REQUIRED_ATTRIBUTES = %w[tool captured].freeze
|
|
30
32
|
TOOL_ALLOWED_ATTRIBUTES = (TOOL_REQUIRED_ATTRIBUTES + %w[tool_call_id]).freeze
|
|
31
|
-
private_constant :USAGE_ALLOWED_ATTRIBUTES, :USAGE_REQUIRED_ATTRIBUTES,
|
|
33
|
+
private_constant :USAGE_ALLOWED_ATTRIBUTES, :USAGE_REQUIRED_ATTRIBUTES, :USAGE_COUNT_ATTRIBUTES, :USAGE_MODES,
|
|
32
34
|
:TOOL_REQUIRED_ATTRIBUTES, :TOOL_ALLOWED_ATTRIBUTES
|
|
33
35
|
|
|
34
36
|
attribute :usage_entries, Types::Array
|
|
@@ -55,6 +57,7 @@ module Smith
|
|
|
55
57
|
validate_bounded_keys!(entry, USAGE_REQUIRED_ATTRIBUTES, USAGE_ALLOWED_ATTRIBUTES, "composite usage entry")
|
|
56
58
|
validate_usage_identity!(entry)
|
|
57
59
|
validate_usage_attribution!(entry)
|
|
60
|
+
validate_usage_details!(entry)
|
|
58
61
|
validate_usage_amount!(entry.fetch("input_tokens"), "input_tokens")
|
|
59
62
|
validate_usage_amount!(entry.fetch("output_tokens"), "output_tokens")
|
|
60
63
|
validate_cost!(entry.fetch("cost"))
|
|
@@ -63,7 +66,7 @@ module Smith
|
|
|
63
66
|
end
|
|
64
67
|
|
|
65
68
|
def usage_totals(entries)
|
|
66
|
-
tokens = entries.sum {
|
|
69
|
+
tokens = entries.sum { entry_tokens(_1) }
|
|
67
70
|
if tokens > PreparedStep::MAX_COUNTER_VALUE
|
|
68
71
|
raise ArgumentError, "composite usage token total exceeds the signed 64-bit limit"
|
|
69
72
|
end
|
|
@@ -76,6 +79,11 @@ module Smith
|
|
|
76
79
|
[tokens, cost]
|
|
77
80
|
end
|
|
78
81
|
|
|
82
|
+
def entry_tokens(entry)
|
|
83
|
+
%w[input_tokens output_tokens].sum { entry.fetch(_1) } +
|
|
84
|
+
%w[cache_read_tokens cache_write_tokens].sum { entry.fetch(_1, 0) }
|
|
85
|
+
end
|
|
86
|
+
|
|
79
87
|
def validate_usage_identity!(entry)
|
|
80
88
|
validate_uuid!(entry.fetch("usage_id"), "composite usage entry usage_id")
|
|
81
89
|
# agent_name and provider are nil-allowed; any other value
|
|
@@ -101,6 +109,21 @@ module Smith
|
|
|
101
109
|
validate_uuid!(entry.fetch("attempt_id"), "composite usage entry attempt_id") if entry.key?("attempt_id")
|
|
102
110
|
end
|
|
103
111
|
|
|
112
|
+
# Optional usage details, bounded like the attribution keys: counts
|
|
113
|
+
# are non-negative Integers, response facts bounded Strings, and the
|
|
114
|
+
# mode one of the three call modes.
|
|
115
|
+
def validate_usage_details!(entry)
|
|
116
|
+
USAGE_COUNT_ATTRIBUTES.each do |key|
|
|
117
|
+
validate_usage_amount!(entry.fetch(key), key) if entry.key?(key)
|
|
118
|
+
end
|
|
119
|
+
%w[stop_reason provider_message_id].each do |key|
|
|
120
|
+
validate_bounded_bytes!(entry.fetch(key), "composite usage entry #{key}", 256) if entry.key?(key)
|
|
121
|
+
end
|
|
122
|
+
return if !entry.key?("mode") || USAGE_MODES.include?(entry.fetch("mode"))
|
|
123
|
+
|
|
124
|
+
raise ArgumentError, "composite usage entry mode is invalid"
|
|
125
|
+
end
|
|
126
|
+
|
|
104
127
|
def validate_uuid!(value, label)
|
|
105
128
|
return if value.is_a?(String) && PreparedStep::UUID_PATTERN.match?(value)
|
|
106
129
|
|
|
@@ -113,6 +136,14 @@ module Smith
|
|
|
113
136
|
raise ArgumentError, "#{label} must be a bounded non-empty String"
|
|
114
137
|
end
|
|
115
138
|
|
|
139
|
+
# Response facts are bounded in bytes where they are read, so they
|
|
140
|
+
# are checked in bytes here.
|
|
141
|
+
def validate_bounded_bytes!(value, label, max_bytes)
|
|
142
|
+
return if value.is_a?(String) && value.bytesize.between?(1, max_bytes)
|
|
143
|
+
|
|
144
|
+
raise ArgumentError, "#{label} must be a non-empty String of at most #{max_bytes} bytes"
|
|
145
|
+
end
|
|
146
|
+
|
|
116
147
|
def validate_nonempty_string!(value, label)
|
|
117
148
|
return if value.is_a?(String) && !value.empty?
|
|
118
149
|
|
|
@@ -10,7 +10,7 @@ module Smith
|
|
|
10
10
|
class Error < Payload
|
|
11
11
|
FAMILIES = %w[
|
|
12
12
|
tool_capture_failed tool_guardrail_failed deterministic_step_failure
|
|
13
|
-
deadline_exceeded agent_error workflow_error other
|
|
13
|
+
deadline_exceeded agent_error workflow_error output_failure other
|
|
14
14
|
].freeze
|
|
15
15
|
ALWAYS_RETRYABLE_FAMILIES = %w[deadline_exceeded agent_error].freeze
|
|
16
16
|
CONDITIONALLY_RETRYABLE_FAMILIES = %w[tool_guardrail_failed deterministic_step_failure].freeze
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "dry-initializer"
|
|
4
4
|
|
|
5
5
|
require_relative "../../errors"
|
|
6
|
+
require_relative "../../output_failure"
|
|
6
7
|
require_relative "../../tool_capture_failed"
|
|
7
8
|
require_relative "error"
|
|
8
9
|
|
|
@@ -10,6 +11,13 @@ module Smith
|
|
|
10
11
|
class Workflow
|
|
11
12
|
module Composite
|
|
12
13
|
class ErrorEvidence
|
|
14
|
+
FAMILIES = [
|
|
15
|
+
[ToolCaptureFailed, "tool_capture_failed"], [ToolGuardrailFailed, "tool_guardrail_failed"],
|
|
16
|
+
[DeterministicStepFailure, "deterministic_step_failure"], [DeadlineExceeded, "deadline_exceeded"],
|
|
17
|
+
[OutputFailure, "output_failure"], [AgentError, "agent_error"], [WorkflowError, "workflow_error"]
|
|
18
|
+
].freeze
|
|
19
|
+
private_constant :FAMILIES
|
|
20
|
+
|
|
13
21
|
extend Dry::Initializer
|
|
14
22
|
|
|
15
23
|
param :error
|
|
@@ -49,15 +57,7 @@ module Smith
|
|
|
49
57
|
end
|
|
50
58
|
|
|
51
59
|
def error_family
|
|
52
|
-
|
|
53
|
-
when ToolCaptureFailed then "tool_capture_failed"
|
|
54
|
-
when ToolGuardrailFailed then "tool_guardrail_failed"
|
|
55
|
-
when DeterministicStepFailure then "deterministic_step_failure"
|
|
56
|
-
when DeadlineExceeded then "deadline_exceeded"
|
|
57
|
-
when AgentError then "agent_error"
|
|
58
|
-
when WorkflowError then "workflow_error"
|
|
59
|
-
else "other"
|
|
60
|
-
end
|
|
60
|
+
FAMILIES.find { |error_class, _family| error.is_a?(error_class) }&.last || "other"
|
|
61
61
|
end
|
|
62
62
|
|
|
63
63
|
def tool_capture_metadata
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../../types"
|
|
4
|
+
require_relative "../message_roles"
|
|
4
5
|
require_relative "../message_value_normalizer"
|
|
5
6
|
require_relative "../prepared_step"
|
|
6
7
|
require_relative "payload"
|
|
@@ -37,10 +38,16 @@ module Smith
|
|
|
37
38
|
private
|
|
38
39
|
|
|
39
40
|
def normalized_values(agent_messages:, session_messages:)
|
|
40
|
-
{
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
values = { agent_messages: normalize(agent_messages), session_messages: normalize(session_messages) }.freeze
|
|
42
|
+
values.each_value { |messages| check_roles!(messages) }
|
|
43
|
+
values
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def check_roles!(messages)
|
|
47
|
+
return unless messages.is_a?(Array)
|
|
48
|
+
return if messages.all? { |message| MessageRoles.role(message) }
|
|
49
|
+
|
|
50
|
+
raise WorkflowError, "composite input messages must have the role system, user, assistant or tool"
|
|
44
51
|
end
|
|
45
52
|
|
|
46
53
|
def normalize(value)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../diagnostic_text"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Workflow
|
|
7
|
+
# The one shape a workflow context has everywhere: top-level keys are
|
|
8
|
+
# Symbols, and every Hash key inside a value is the Symbol of its String
|
|
9
|
+
# form, which is exactly what a JSON checkpoint and restore produce, so a
|
|
10
|
+
# key reads the same live and after a round trip. Values are not
|
|
11
|
+
# converted; only JSON-native values (Strings, numbers, booleans, nil,
|
|
12
|
+
# Hashes, Arrays) round-trip unchanged.
|
|
13
|
+
module ContextKeys
|
|
14
|
+
# JSON's own default nesting limit: a deeper context could not be
|
|
15
|
+
# checkpointed anyway.
|
|
16
|
+
MAX_DEPTH = 100
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# A context given to `Workflow.new` or to a class-level helper that
|
|
21
|
+
# derives a persistence key from it. Top-level keys must already be
|
|
22
|
+
# Symbols, the rule `write_context` applies to every key it writes.
|
|
23
|
+
def normalize_context(context)
|
|
24
|
+
raise WorkflowError, "workflow context must be a Hash, got #{context.class}" unless context.is_a?(Hash)
|
|
25
|
+
|
|
26
|
+
context.each_key { |key| validate_top_level_key!(key) }
|
|
27
|
+
normalize(context)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def validate_top_level_key!(key)
|
|
31
|
+
return if key.is_a?(Symbol)
|
|
32
|
+
|
|
33
|
+
raise WorkflowError,
|
|
34
|
+
"workflow context keys must be Symbols, got #{bounded(key.inspect)} (#{key.class}); " \
|
|
35
|
+
"convert them before building the workflow"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# A copy of `value` with every Hash key at every depth turned into the
|
|
39
|
+
# Symbol of its String form. Raises WorkflowError when two keys of one
|
|
40
|
+
# Hash become the same Symbol, when a container contains itself, or
|
|
41
|
+
# past MAX_DEPTH. `O(N)` in the number of Hash entries and Array
|
|
42
|
+
# elements.
|
|
43
|
+
def normalize(value, path = {}.compare_by_identity, depth = 0)
|
|
44
|
+
case value
|
|
45
|
+
when Hash then within(value, path, depth) { normalize_hash(value, path, depth) }
|
|
46
|
+
when Array then within(value, path, depth) { value.map { |item| normalize(item, path, depth + 1) } }
|
|
47
|
+
else value
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def normalize_hash(hash, path, depth)
|
|
52
|
+
hash.each_with_object({}) do |(key, nested), normalized|
|
|
53
|
+
symbol = key.is_a?(Symbol) ? key : key.to_s.to_sym
|
|
54
|
+
if normalized.key?(symbol)
|
|
55
|
+
raise WorkflowError, "workflow context has two keys that both become #{bounded(symbol.inspect)}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
normalized[symbol] = normalize(nested, path, depth + 1)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def within(container, path, depth)
|
|
63
|
+
raise WorkflowError, "workflow context is nested deeper than #{MAX_DEPTH} levels" if depth > MAX_DEPTH
|
|
64
|
+
raise WorkflowError, "workflow context contains itself" if path.key?(container)
|
|
65
|
+
|
|
66
|
+
path[container] = true
|
|
67
|
+
begin
|
|
68
|
+
yield
|
|
69
|
+
ensure
|
|
70
|
+
path.delete(container)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def bounded(text) = DiagnosticText.capture(text, max_bytes: 256)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -123,7 +123,7 @@ module Smith
|
|
|
123
123
|
|
|
124
124
|
def compute_wall_clock_deadline
|
|
125
125
|
limit = self.class.budget&.dig(:wall_clock)
|
|
126
|
-
own_deadline = limit ?
|
|
126
|
+
own_deadline = limit ? active_deadline(limit) : nil
|
|
127
127
|
|
|
128
128
|
return own_deadline unless @inherited_deadline
|
|
129
129
|
return @inherited_deadline unless own_deadline
|
|
@@ -8,19 +8,56 @@ module Smith
|
|
|
8
8
|
def execute_deterministic_step(transition)
|
|
9
9
|
check_deadline!
|
|
10
10
|
step = build_deterministic_step(transition)
|
|
11
|
-
emit_deterministic_trace(transition,
|
|
11
|
+
emit_deterministic_trace(transition, outcome: :started)
|
|
12
12
|
transition.deterministic_block.call(step)
|
|
13
|
+
finish_deterministic_step!(transition, step)
|
|
14
|
+
nil
|
|
15
|
+
rescue StandardError => e
|
|
16
|
+
emit_deterministic_trace(transition, outcome: :failed, failure: deterministic_failure_fields(transition, e))
|
|
17
|
+
raise
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# A write the step body rescued still fails the step, a declared route
|
|
21
|
+
# must be taken, and only then are writes applied and success traced.
|
|
22
|
+
def finish_deterministic_step!(transition, step)
|
|
23
|
+
raise step.write_violation if step.write_violation
|
|
24
|
+
|
|
25
|
+
ensure_deterministic_routed!(transition, step)
|
|
13
26
|
apply_deterministic_writes!(step)
|
|
14
27
|
emit_deterministic_trace(
|
|
15
28
|
transition,
|
|
16
|
-
|
|
29
|
+
outcome: step.routed_to ? :routed : :success,
|
|
17
30
|
routed_to: step.routed_to,
|
|
18
31
|
outcome_kind: step.outcome&.dig(:kind)
|
|
19
32
|
)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The bounded classification failed :transition traces carry, never
|
|
36
|
+
# the raw message, which may hold content. Skipped when nothing would
|
|
37
|
+
# record the trace, and never allowed to replace the step's own error.
|
|
38
|
+
def deterministic_failure_fields(transition, error)
|
|
39
|
+
return {} unless Smith::Trace.resolve_adapter
|
|
40
|
+
|
|
41
|
+
failure = FailureRecord.capture({ transition: transition.name, from: transition.from, to: transition.to,
|
|
42
|
+
error: error })
|
|
43
|
+
fields = { error_class: failure[:error_class], error_family: failure[:error_family] }
|
|
44
|
+
fields[:retryable] = failure[:error_retryable] unless failure[:error_retryable].nil?
|
|
45
|
+
fields
|
|
46
|
+
rescue StandardError
|
|
47
|
+
{ error_class: DiagnosticText.error_class_name(error) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# A step declaring `routes:` must route, unless it declares `on_success`,
|
|
51
|
+
# opts into `fallthrough: true`, or its target state has no outgoing
|
|
52
|
+
# transition (the run then ends there, as declared); otherwise the run
|
|
53
|
+
# would silently take the first transition from the target state.
|
|
54
|
+
# Raised before any write is applied, like any failing step.
|
|
55
|
+
def ensure_deterministic_routed!(transition, step)
|
|
56
|
+
return if step.routed_to || transition.deterministic_routes.nil?
|
|
57
|
+
return if transition.deterministic_fallthrough || transition.success_transition
|
|
58
|
+
return unless self.class.transition_from?(transition.to)
|
|
59
|
+
|
|
60
|
+
raise UnroutedDeterministicStep.new(transition: transition.name, routes: transition.deterministic_routes)
|
|
24
61
|
end
|
|
25
62
|
|
|
26
63
|
def build_deterministic_step(transition)
|
|
@@ -30,10 +67,21 @@ module Smith
|
|
|
30
67
|
tool_results: snapshot_value(@tool_results || []),
|
|
31
68
|
state: @state,
|
|
32
69
|
transition: transition,
|
|
33
|
-
last_agent_execution: snapshot_value(@last_agent_execution)
|
|
70
|
+
last_agent_execution: snapshot_value(@last_agent_execution),
|
|
71
|
+
last_output: snapshot_value(@last_output),
|
|
72
|
+
persisted_keys: declared_persist_keys,
|
|
73
|
+
idempotency_scope: IdempotencyKey.__send__(:current_scope),
|
|
74
|
+
attempt: @step_attempt || 1
|
|
34
75
|
)
|
|
35
76
|
end
|
|
36
77
|
|
|
78
|
+
# The explicit `persist :a, :b` list a deterministic write must stay
|
|
79
|
+
# within, or nil when the workflow declares none or uses `persist :auto`.
|
|
80
|
+
def declared_persist_keys
|
|
81
|
+
keys = resolve_persist_keys
|
|
82
|
+
keys.is_a?(Array) ? keys : nil
|
|
83
|
+
end
|
|
84
|
+
|
|
37
85
|
def apply_deterministic_writes!(step)
|
|
38
86
|
@context.merge!(step.context_writes)
|
|
39
87
|
step.context_writes.each_key { |key| record_persisted_key!(key) }
|
|
@@ -41,11 +89,14 @@ module Smith
|
|
|
41
89
|
@outcome = symbolize_value(snapshot_value(step.outcome)) if step.outcome
|
|
42
90
|
end
|
|
43
91
|
|
|
44
|
-
|
|
92
|
+
# `outcome`, not `result`: `result` is a reserved content key that the
|
|
93
|
+
# default content policy strips, matching the :transition and
|
|
94
|
+
# :provider_call vocabulary.
|
|
95
|
+
def emit_deterministic_trace(transition, outcome:, routed_to: nil, failure: nil, outcome_kind: nil)
|
|
45
96
|
data = { transition: transition.name, from: transition.from, to: transition.to,
|
|
46
|
-
kind: transition.deterministic_kind,
|
|
97
|
+
kind: transition.deterministic_kind, outcome: outcome }
|
|
47
98
|
data[:routed_to] = routed_to if routed_to
|
|
48
|
-
data
|
|
99
|
+
data.merge!(failure) if failure
|
|
49
100
|
data[:outcome_kind] = outcome_kind if outcome_kind
|
|
50
101
|
Smith::Trace.record(type: :deterministic_step, data: data)
|
|
51
102
|
end
|
|
@@ -4,7 +4,9 @@ module Smith
|
|
|
4
4
|
class Workflow
|
|
5
5
|
class DeterministicStep
|
|
6
6
|
attr_reader :context, :tool_results, :session_messages, :current_state, :transition_name,
|
|
7
|
-
:context_writes, :routed_to, :outcome, :allowed_routes
|
|
7
|
+
:context_writes, :routed_to, :outcome, :allowed_routes, :write_violation
|
|
8
|
+
# The attempt under way, from 1; above 1 only under `retry_on`.
|
|
9
|
+
attr_reader :attempt
|
|
8
10
|
|
|
9
11
|
def initialize(context:, session_messages:, tool_results:, state:, **options)
|
|
10
12
|
validate_options!(options)
|
|
@@ -15,7 +17,7 @@ module Smith
|
|
|
15
17
|
@current_state = state
|
|
16
18
|
@transition_name = transition ? transition.name : options.fetch(:transition_name)
|
|
17
19
|
@allowed_routes = snapshot_allowed_routes(transition ? transition.deterministic_routes : options[:allowed_routes])
|
|
18
|
-
|
|
20
|
+
assign_execution_facts(options)
|
|
19
21
|
@context_writes = {}
|
|
20
22
|
@routed_to = nil
|
|
21
23
|
@outcome = nil
|
|
@@ -33,7 +35,11 @@ module Smith
|
|
|
33
35
|
@last_agent_execution && @last_agent_execution[:provider]
|
|
34
36
|
end
|
|
35
37
|
|
|
38
|
+
# The output of the most recent step that produced one, whether it
|
|
39
|
+
# went to the transcript or to an `output_to:` key; before any step
|
|
40
|
+
# has, the last assistant message in the transcript.
|
|
36
41
|
def last_output
|
|
42
|
+
return @step_output unless @step_output.nil?
|
|
37
43
|
return @last_output if defined?(@last_output)
|
|
38
44
|
|
|
39
45
|
msg = session_messages.reverse.find { |m| (m[:role] || m["role"]).to_s == "assistant" }
|
|
@@ -46,10 +52,27 @@ module Smith
|
|
|
46
52
|
@context_writes.key?(key) ? @context_writes[key] : context[key]
|
|
47
53
|
end
|
|
48
54
|
|
|
55
|
+
# The value is stored in the context's key shape at once, so a
|
|
56
|
+
# `read_context` later in the same step sees what the next step will.
|
|
57
|
+
# A rejected write is remembered as well as raised, so a `rescue` in
|
|
58
|
+
# the step body cannot turn it into a silently dropped write.
|
|
49
59
|
def write_context(key, value)
|
|
50
60
|
raise WorkflowError, "write_context key must be a Symbol, got #{key.class}" unless key.is_a?(Symbol)
|
|
51
61
|
|
|
52
|
-
@
|
|
62
|
+
if @persisted_keys && !@persisted_keys.include?(key)
|
|
63
|
+
reject_write!(UndeclaredContextKey.new(key:, persisted_keys: @persisted_keys))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
@context_writes[key] = begin
|
|
67
|
+
ContextKeys.normalize(value)
|
|
68
|
+
rescue WorkflowError => e
|
|
69
|
+
reject_write!(e)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def reject_write!(error)
|
|
74
|
+
@write_violation ||= error
|
|
75
|
+
raise error
|
|
53
76
|
end
|
|
54
77
|
|
|
55
78
|
def route_to(transition_name)
|
|
@@ -65,14 +88,40 @@ module Smith
|
|
|
65
88
|
@outcome = { kind: kind, payload: payload }
|
|
66
89
|
end
|
|
67
90
|
|
|
68
|
-
def fail!(message, retryable: nil, kind: nil, details: nil)
|
|
69
|
-
|
|
91
|
+
def fail!(message, retryable: nil, kind: nil, details: nil, new_idempotency_key: false)
|
|
92
|
+
unless [true, false, nil].include?(new_idempotency_key)
|
|
93
|
+
raise ArgumentError, "fail! new_idempotency_key must be true or false"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
raise DeterministicStepFailure.new(message, retryable:, kind:, details:, new_idempotency_key:,
|
|
97
|
+
idempotency_scope: @idempotency_scope)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# A key for a request this step sends outside the system: the same on
|
|
101
|
+
# every attempt of the step and after a restore, unique to this run,
|
|
102
|
+
# step and `scope`. Use a scope to tell apart several requests of one
|
|
103
|
+
# step. See Smith::IdempotencyKey.
|
|
104
|
+
def idempotency_key(scope = nil)
|
|
105
|
+
unless @idempotency_scope
|
|
106
|
+
raise WorkflowError, "an idempotency key is only available inside a running workflow step"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
@idempotency_scope.key(scope)
|
|
70
110
|
end
|
|
71
111
|
|
|
72
112
|
private
|
|
73
113
|
|
|
114
|
+
def assign_execution_facts(options)
|
|
115
|
+
@last_agent_execution = options[:last_agent_execution]
|
|
116
|
+
@step_output = options[:last_output]
|
|
117
|
+
@persisted_keys = options[:persisted_keys]&.dup&.freeze
|
|
118
|
+
@idempotency_scope = options[:idempotency_scope]
|
|
119
|
+
@attempt = options.fetch(:attempt, 1)
|
|
120
|
+
end
|
|
121
|
+
|
|
74
122
|
def validate_options!(options)
|
|
75
|
-
unknown = options.keys - %i[transition transition_name allowed_routes last_agent_execution
|
|
123
|
+
unknown = options.keys - %i[transition transition_name allowed_routes last_agent_execution last_output
|
|
124
|
+
persisted_keys idempotency_scope attempt]
|
|
76
125
|
raise ArgumentError, "unknown keywords: #{unknown.join(", ")}" if unknown.any?
|
|
77
126
|
return if options[:transition] || options.key?(:transition_name)
|
|
78
127
|
|
data/lib/smith/workflow/dsl.rb
CHANGED
|
@@ -24,6 +24,8 @@ module Smith
|
|
|
24
24
|
subclass.instance_variable_set(:@transition_sequence, @transition_sequence)
|
|
25
25
|
subclass.instance_variable_set(:@initial_state_name, @initial_state_name)
|
|
26
26
|
subclass.instance_variable_set(:@budget_config, @budget_config&.dup)
|
|
27
|
+
subclass.instance_variable_set(:@budget_ledger_builder, @budget_ledger_builder)
|
|
28
|
+
subclass.instance_variable_set(:@budget_ledger_scope_keys, @budget_ledger_scope_keys)
|
|
27
29
|
subclass.instance_variable_set(:@max_transitions_count, @max_transitions_count)
|
|
28
30
|
subclass.instance_variable_set(:@guardrails_class, @guardrails_class)
|
|
29
31
|
subclass.instance_variable_set(:@context_manager_class, @context_manager_class)
|
|
@@ -33,6 +35,7 @@ module Smith
|
|
|
33
35
|
subclass.instance_variable_set(:@migrations, (@migrations || {}).dup)
|
|
34
36
|
subclass.instance_variable_set(:@seed_validation_mode, @seed_validation_mode)
|
|
35
37
|
subclass.instance_variable_set(:@idempotency_mode, @idempotency_mode)
|
|
38
|
+
subclass.instance_variable_set(:@session_mode, @session_mode)
|
|
36
39
|
subclass.instance_variable_set(:@persistence_ttl, @persistence_ttl)
|
|
37
40
|
end
|
|
38
41
|
|
|
@@ -76,6 +79,23 @@ module Smith
|
|
|
76
79
|
@budget_config = opts
|
|
77
80
|
end
|
|
78
81
|
|
|
82
|
+
# The host's budget ledger for a run (Budget::HostLedger): the block
|
|
83
|
+
# gets the run's scope, the values `scope_keys` have in its trusted
|
|
84
|
+
# context when it is created, kept with the run, and returns the
|
|
85
|
+
# ledger, or nil for none. It is built at the first request a step
|
|
86
|
+
# makes, never persisted. Each provider request a step makes is held
|
|
87
|
+
# on it first.
|
|
88
|
+
def budget_ledger(*scope_keys, &block)
|
|
89
|
+
return @budget_ledger_builder if scope_keys.empty? && !block
|
|
90
|
+
raise ArgumentError, "budget_ledger needs a block that returns the ledger" unless block
|
|
91
|
+
raise ArgumentError, "budget_ledger scope keys must be Symbols" unless scope_keys.all?(Symbol)
|
|
92
|
+
|
|
93
|
+
@budget_ledger_scope_keys = scope_keys.uniq.freeze
|
|
94
|
+
@budget_ledger_builder = block
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def budget_ledger_scope_keys = @budget_ledger_scope_keys || []
|
|
98
|
+
|
|
79
99
|
def max_transitions(count = nil)
|
|
80
100
|
return @max_transitions_count if count.nil?
|
|
81
101
|
|
|
@@ -185,6 +205,20 @@ module Smith
|
|
|
185
205
|
# set, indicating a previous worker crashed mid-step
|
|
186
206
|
# and re-running could double-execute non-idempotent
|
|
187
207
|
# agent calls or tools.
|
|
208
|
+
# `:shared` (the default) keeps one transcript: each agent step sees
|
|
209
|
+
# the session and its accepted output is appended to it. `:isolated`
|
|
210
|
+
# keeps no transcript for agents: every agent step must declare
|
|
211
|
+
# `input:`, its output is never appended, and `inject_state` does
|
|
212
|
+
# not run for it.
|
|
213
|
+
def session(mode = nil)
|
|
214
|
+
return @session_mode || :shared if mode.nil?
|
|
215
|
+
unless %i[shared isolated].include?(mode)
|
|
216
|
+
raise ArgumentError, "session must be :shared or :isolated, got #{mode.inspect}"
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
@session_mode = mode
|
|
220
|
+
end
|
|
221
|
+
|
|
188
222
|
def idempotency_mode(mode = nil)
|
|
189
223
|
return @idempotency_mode || :lax if mode.nil?
|
|
190
224
|
|
|
@@ -238,6 +272,31 @@ module Smith
|
|
|
238
272
|
(@transitions || {})[name]
|
|
239
273
|
end
|
|
240
274
|
|
|
275
|
+
# The transition whose name has this String form, for a name read
|
|
276
|
+
# back from a payload. `O(T)`.
|
|
277
|
+
def transition_named(name)
|
|
278
|
+
return if name.nil?
|
|
279
|
+
|
|
280
|
+
find_transition(name) ||
|
|
281
|
+
(@transitions || {}).each_value.find { |transition| transition.name.to_s == name.to_s }
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# A state this class can hold: declared with `state` (or
|
|
285
|
+
# `initial_state`), or named by any transition's `from:` or `to:`.
|
|
286
|
+
# Matched by String form, so a payload's "prepared" finds a state
|
|
287
|
+
# declared as :prepared or "prepared". `O(S + T)`.
|
|
288
|
+
def known_state?(name) = !resolve_state_name(name).nil?
|
|
289
|
+
|
|
290
|
+
# The identifier this class declares for `name` (matched by String
|
|
291
|
+
# form), or nil when the class knows no such state.
|
|
292
|
+
def resolve_state_name(name)
|
|
293
|
+
return if name.nil?
|
|
294
|
+
|
|
295
|
+
wanted = name.to_s
|
|
296
|
+
candidates = (@states || []) + (@transitions || {}).each_value.flat_map { |t| [t.from, t.to] }
|
|
297
|
+
candidates.find { |candidate| !candidate.nil? && candidate.to_s == wanted }
|
|
298
|
+
end
|
|
299
|
+
|
|
241
300
|
def transition_at(index)
|
|
242
301
|
(@transitions || {}).values.fetch(index)
|
|
243
302
|
end
|