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
|
@@ -7,29 +7,86 @@ module Smith
|
|
|
7
7
|
|
|
8
8
|
# `@step_attempt` is the attempt under way, from 1, which the optimizer
|
|
9
9
|
# names its verdicts by; transient, set here before every attempt.
|
|
10
|
+
#
|
|
11
|
+
# Every attempt sends its requests under the step's idempotency keys,
|
|
12
|
+
# so a retry after an uncertain failure (a timeout, a lost connection)
|
|
13
|
+
# cannot repeat a request that took effect. Only an attempt that
|
|
14
|
+
# failed in a way the step declares settled moves later attempts to
|
|
15
|
+
# new keys. Attempts replay in the same order after a restore, so a
|
|
16
|
+
# replay reaches the same keys as long as the receivers answer the
|
|
17
|
+
# replayed requests as before. A durable policy waits out the backoff
|
|
18
|
+
# as a wait of the run (RetryWait) and resumes at the attempt and key
|
|
19
|
+
# generation it recorded.
|
|
10
20
|
def run_with_retry_policy(transition)
|
|
11
21
|
config = transition.retry_config
|
|
12
|
-
|
|
22
|
+
position = pending_batch_position
|
|
23
|
+
@step_attempt = position ? position.first + 1 : 1
|
|
24
|
+
@step_key_generation = position ? position.last : 0
|
|
25
|
+
start_budget_pause(transition)
|
|
26
|
+
start_memo_calls
|
|
13
27
|
return run_guarded_step(transition) unless config
|
|
14
28
|
|
|
29
|
+
run_attempts(transition, config, *(position || pending_retry_position(transition)))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Attempts after the `attempt` already made, under `key_generation`.
|
|
33
|
+
def run_attempts(transition, config, attempt, key_generation)
|
|
15
34
|
schedule = retry_schedule(config)
|
|
16
|
-
attempt = 0
|
|
17
35
|
begin
|
|
18
36
|
attempt += 1
|
|
19
37
|
@step_attempt = attempt
|
|
20
|
-
|
|
38
|
+
@step_key_generation = key_generation
|
|
39
|
+
attempt_scope = attempt_idempotency_scope(key_generation)
|
|
40
|
+
run_attempt_under_keys(transition, attempt_scope)
|
|
21
41
|
rescue StandardError => e
|
|
22
42
|
raise unless retry_transition_error?(config, e, attempt)
|
|
23
43
|
|
|
24
|
-
|
|
44
|
+
key_generation += 1 if new_idempotency_key_after?(config, e, attempt_scope)
|
|
45
|
+
keep_budget_continuation(e)
|
|
46
|
+
delay = retry_delay(schedule, attempt)
|
|
47
|
+
return wait_to_retry(transition, delay, attempt + 1, key_generation, e) if config[:durable]
|
|
48
|
+
|
|
49
|
+
sleep(delay) if delay.positive?
|
|
25
50
|
retry
|
|
26
51
|
end
|
|
27
52
|
end
|
|
28
53
|
|
|
54
|
+
def attempt_idempotency_scope(key_generation)
|
|
55
|
+
step_scope = IdempotencyKey.__send__(:current_scope)
|
|
56
|
+
return step_scope if step_scope.nil? || key_generation.zero?
|
|
57
|
+
|
|
58
|
+
step_scope.within(:key_generation, key_generation)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def run_attempt_under_keys(transition, attempt_scope)
|
|
62
|
+
return run_guarded_step(transition) if attempt_scope.equal?(IdempotencyKey.__send__(:current_scope))
|
|
63
|
+
|
|
64
|
+
IdempotencyKey.__send__(:swap, attempt_scope) { run_guarded_step(transition) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# A `fail!(new_idempotency_key: true)` counts only when this step's own
|
|
68
|
+
# attempt raised it, never a nested workflow's step, whose flag speaks
|
|
69
|
+
# for its own requests and not for the earlier steps of the attempt.
|
|
70
|
+
def new_idempotency_key_after?(config, error, attempt_scope)
|
|
71
|
+
return true if own_settled_failure?(error, attempt_scope)
|
|
72
|
+
|
|
73
|
+
config.fetch(:new_idempotency_key_on, EMPTY_ERROR_CLASSES).any? { |error_class| error.is_a?(error_class) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def own_settled_failure?(error, attempt_scope)
|
|
77
|
+
error.is_a?(DeterministicStepFailure) && error.new_idempotency_key &&
|
|
78
|
+
!attempt_scope.nil? && error.idempotency_scope == attempt_scope
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
EMPTY_ERROR_CLASSES = [].freeze
|
|
82
|
+
private_constant :EMPTY_ERROR_CLASSES
|
|
83
|
+
|
|
29
84
|
def retry_transition_error?(config, error, attempt)
|
|
30
85
|
return false if attempt >= config.fetch(:attempts)
|
|
31
86
|
return false if terminal_retry_error?(error)
|
|
32
87
|
|
|
88
|
+
return false if retry_deadline_passed?
|
|
89
|
+
|
|
33
90
|
classes = config.fetch(:error_classes)
|
|
34
91
|
if classes.any?
|
|
35
92
|
classes.any? { |error_class| error.is_a?(error_class) }
|
|
@@ -38,13 +95,15 @@ module Smith
|
|
|
38
95
|
end
|
|
39
96
|
end
|
|
40
97
|
|
|
41
|
-
|
|
42
|
-
|
|
98
|
+
# No attempt starts once the wall-clock deadline has passed: it could
|
|
99
|
+
# only fail again, after sleeping through its backoff.
|
|
100
|
+
def retry_deadline_passed?
|
|
101
|
+
deadline = effective_deadline
|
|
102
|
+
!deadline.nil? && Time.now.utc >= deadline
|
|
43
103
|
end
|
|
44
104
|
|
|
45
|
-
def
|
|
46
|
-
|
|
47
|
-
sleep(delay) if delay.positive?
|
|
105
|
+
def terminal_retry_error?(error)
|
|
106
|
+
Smith::Errors.retry_forbidden?(error)
|
|
48
107
|
end
|
|
49
108
|
|
|
50
109
|
def retry_delay(config_or_schedule, failed_attempt)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Workflow
|
|
7
|
+
# The waits of durable retries. An attempt that fails in a way a
|
|
8
|
+
# `retry_on ..., durable: true` policy retries does not sleep: the run
|
|
9
|
+
# enters a wait of kind `retry`, due when the backoff ends, and records
|
|
10
|
+
# its pending retry (the transition, the next attempt, its key
|
|
11
|
+
# generation, and the class of the error that failed the last attempt).
|
|
12
|
+
# `expire_wait!` past the deadline ends the wait without a step and
|
|
13
|
+
# leaves the transition as the run's next; the host continues the run as
|
|
14
|
+
# after any wait, and the step makes the recorded attempt under the
|
|
15
|
+
# recorded keys. A step that ends without entering a wait clears the
|
|
16
|
+
# pending retry (Workflow#advance!).
|
|
17
|
+
module RetryWait
|
|
18
|
+
PENDING_RETRY_FIELDS = %w[attempt error key_generation transition].freeze
|
|
19
|
+
private_constant :PENDING_RETRY_FIELDS
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
# Enters the wait, due after the backoff, under a token of the attempt
|
|
24
|
+
# it waits to make. Like any wait it is not a step. The pending retry
|
|
25
|
+
# is recorded before the wait, so an interrupt between the two leaves
|
|
26
|
+
# a step that ended without a wait, whose pending retry is cleared.
|
|
27
|
+
def wait_to_retry(transition, seconds, attempt, key_generation, error)
|
|
28
|
+
if @split_step_phase == :executing
|
|
29
|
+
raise WorkflowError, "a durable retry cannot wait inside split-step execution"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
check_wait_ttl!(seconds)
|
|
33
|
+
error_name = DiagnosticText.error_class_name(error)
|
|
34
|
+
@pending_retry = { "transition" => transition.name.to_s, "attempt" => attempt,
|
|
35
|
+
"key_generation" => key_generation, "error" => error_name,
|
|
36
|
+
"continuation" => @budget_continuation }.compact.freeze
|
|
37
|
+
@retry_wait_error = error
|
|
38
|
+
@awaiting = WaitRecord.build_retry(transition, wait_token_for(transition, ["retry", attempt]), seconds,
|
|
39
|
+
Time.now.utc)
|
|
40
|
+
record_retry_trace(transition, :retrying, attempt:, error: error_name, deadline_at: @awaiting["deadline_at"])
|
|
41
|
+
WAIT_PENDING
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# A step that ran and did not enter a wait has made its pending retry,
|
|
45
|
+
# whether it completed, failed or raised.
|
|
46
|
+
def clear_settled_pending_retry
|
|
47
|
+
@pending_retry = nil if step_work_started? && !waiting?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# [attempts already made, key generation] where the step starts: the
|
|
51
|
+
# pending retry's for its transition, else [0, 0].
|
|
52
|
+
def pending_retry_position(transition)
|
|
53
|
+
pending = @pending_retry
|
|
54
|
+
return [0, 0] unless pending && pending["transition"] == transition.name.to_s
|
|
55
|
+
|
|
56
|
+
[pending["attempt"] - 1, pending["key_generation"]]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The backoff is over: the transition becomes the run's next step, then
|
|
60
|
+
# the wait ends; nothing counts. Returns :expired.
|
|
61
|
+
def end_retry_wait(transition)
|
|
62
|
+
@next_transition_name = transition.name
|
|
63
|
+
@updated_at = Time.now.utc.iso8601(6)
|
|
64
|
+
@awaiting = nil
|
|
65
|
+
Attribution.with(execution_key: @persistence_key, workflow: self.class.name || "anonymous") do
|
|
66
|
+
record_retry_trace(transition, :retry_due, attempt: @pending_retry&.fetch("attempt"))
|
|
67
|
+
end
|
|
68
|
+
:expired
|
|
69
|
+
ensure
|
|
70
|
+
@wait_claimed = false
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def record_retry_trace(transition, outcome, **details)
|
|
74
|
+
Smith::Trace.record(
|
|
75
|
+
type: :transition,
|
|
76
|
+
data: { transition: transition.name, from: transition.from, to: transition.to, outcome:, **details }
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The error that failed the attempt a retry wait follows, while this
|
|
81
|
+
# instance holds it (for a nested child's parent).
|
|
82
|
+
def retry_wait_error = (@retry_wait_error if retry_waiting?)
|
|
83
|
+
|
|
84
|
+
# A persisted pending retry names the transition of the stored retry
|
|
85
|
+
# wait, or else the run's next one, with an attempt after the first, a
|
|
86
|
+
# key generation an earlier attempt could reach, and an error class
|
|
87
|
+
# name; a stored retry wait needs one. A malformed one raises
|
|
88
|
+
# SerializationError; one the class no longer allows (the transition
|
|
89
|
+
# declares no durable retry, or fewer attempts) is refused like an
|
|
90
|
+
# unknown position.
|
|
91
|
+
def restore_pending_retry(value, persistence_key)
|
|
92
|
+
return if value.nil? && !retry_waiting?
|
|
93
|
+
|
|
94
|
+
record = value.is_a?(Hash) ? value.transform_keys(&:to_s) : {}
|
|
95
|
+
transition = pending_retry_transition(record)
|
|
96
|
+
raise Smith::SerializationError, "persisted workflow pending_retry is invalid" unless
|
|
97
|
+
well_formed_pending_retry?(transition, record)
|
|
98
|
+
|
|
99
|
+
refuse_disallowed_pending_retry!(transition, record, persistence_key)
|
|
100
|
+
record.transform_values(&:freeze).freeze
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def retry_waiting? = @awaiting&.fetch("kind") == "retry"
|
|
104
|
+
|
|
105
|
+
# The transition the record names, when it is the stored retry wait's,
|
|
106
|
+
# or the run's next while the run does not wait.
|
|
107
|
+
def pending_retry_transition(record)
|
|
108
|
+
return if @awaiting && !retry_waiting?
|
|
109
|
+
|
|
110
|
+
name = @awaiting ? @awaiting["transition"] : @next_transition_name&.to_s
|
|
111
|
+
self.class.transition_named(name) if name && record["transition"] == name
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def well_formed_pending_retry?(transition, record)
|
|
115
|
+
!transition.nil? && (record.keys - ["continuation"]).sort == PENDING_RETRY_FIELDS &&
|
|
116
|
+
pending_retry_fields?(record) &&
|
|
117
|
+
(!record.key?("continuation") || Budget::Continuation.valid?(record["continuation"]))
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def pending_retry_fields?(record)
|
|
121
|
+
attempt, generation, error = record.values_at("attempt", "key_generation", "error")
|
|
122
|
+
attempt.is_a?(Integer) && attempt >= 2 && generation.is_a?(Integer) && generation.between?(0, attempt - 1) &&
|
|
123
|
+
error.is_a?(String) && error.bytesize.between?(1, 256)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def refuse_disallowed_pending_retry!(transition, record, persistence_key)
|
|
127
|
+
return if transition.durable_retry? && record["attempt"] <= transition.retry_config.fetch(:attempts)
|
|
128
|
+
|
|
129
|
+
raise Smith::UnknownTransitionOnRestore.new(workflow: self.class.name, persistence_key:,
|
|
130
|
+
transition: transition.name)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -35,6 +35,9 @@ module Smith
|
|
|
35
35
|
end
|
|
36
36
|
@split_step_mutex.synchronize { @split_step_phase = :checkpointed } if checkpoint_claimed
|
|
37
37
|
result
|
|
38
|
+
rescue PersistedStateTooLarge
|
|
39
|
+
release_split_step_checkpoint! if checkpoint_claimed
|
|
40
|
+
raise
|
|
38
41
|
rescue StandardError
|
|
39
42
|
mark_split_step_checkpoint_unknown! if checkpoint_claimed
|
|
40
43
|
raise
|
|
@@ -40,6 +40,13 @@ module Smith
|
|
|
40
40
|
end
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
+
# A checkpoint refused before anything was written may be made again.
|
|
44
|
+
def release_split_step_checkpoint!
|
|
45
|
+
@split_step_mutex.synchronize do
|
|
46
|
+
@split_step_phase = :checkpoint_retryable if @split_step_phase == :checkpointing
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
43
50
|
def mark_split_step_checkpoint_unknown!
|
|
44
51
|
@split_step_mutex.synchronize do
|
|
45
52
|
@split_step_phase = :checkpoint_unknown if @split_step_phase == :checkpointing
|
|
@@ -46,7 +46,10 @@ module Smith
|
|
|
46
46
|
# (execution key from the restored persistence key, the split
|
|
47
47
|
# step's transition, then the branch overlay downstream).
|
|
48
48
|
# Restoration is owned by within_raw_step_context's snapshot.
|
|
49
|
-
|
|
49
|
+
if @split_step_transition
|
|
50
|
+
install_step_attribution(@split_step_transition)
|
|
51
|
+
install_step_idempotency_scope(@split_step_transition)
|
|
52
|
+
end
|
|
50
53
|
with_scoped_artifacts(&block)
|
|
51
54
|
end
|
|
52
55
|
[output, nil]
|
|
@@ -33,8 +33,10 @@ module Smith
|
|
|
33
33
|
|
|
34
34
|
def prepare_composite_input(authorization, transition, specs)
|
|
35
35
|
ThreadContextSnapshot.new.around do
|
|
36
|
+
# The step's position, as in-process guardrails run with it.
|
|
37
|
+
install_step_idempotency_scope(transition)
|
|
36
38
|
run_composite_input_guardrails(authorization, transition, specs)
|
|
37
|
-
agent_messages =
|
|
39
|
+
agent_messages = step_prepared_input(transition)
|
|
38
40
|
Composite::Input.build(agent_messages:, session_messages: snapshot_session_messages)
|
|
39
41
|
end
|
|
40
42
|
end
|
|
@@ -70,10 +70,15 @@ module Smith
|
|
|
70
70
|
end
|
|
71
71
|
|
|
72
72
|
def prepare_claimed_persisted_step!(key, adapter)
|
|
73
|
+
raise WorkflowError, "split-step persistence cannot prepare a step while the run waits" if waiting?
|
|
74
|
+
|
|
73
75
|
transition = pending_split_step_transition
|
|
74
76
|
transition_name = @next_transition_name || transition&.name
|
|
75
77
|
raise WorkflowError, "workflow has no pending transition" unless transition_name
|
|
76
78
|
|
|
79
|
+
refusal = "split-step persistence does not run steps that wait or launch runs"
|
|
80
|
+
raise WorkflowError, refusal if transition&.waits_or_launches?
|
|
81
|
+
|
|
77
82
|
ensure_transition_budget!
|
|
78
83
|
resolved_key = normalize_split_step_persistence_key(candidate_split_step_persistence_key(key))
|
|
79
84
|
store = persistence_adapter!(adapter)
|
|
@@ -58,6 +58,7 @@ module Smith
|
|
|
58
58
|
|
|
59
59
|
def detach_split_step_results!
|
|
60
60
|
@last_output = snapshot_value(@last_output)
|
|
61
|
+
@last_transcript_output = snapshot_value(@last_transcript_output)
|
|
61
62
|
@last_agent_execution = snapshot_value(@last_agent_execution)
|
|
62
63
|
@last_failed_step = snapshot_value(@last_failed_step)
|
|
63
64
|
@last_prepared_input = snapshot_value(@last_prepared_input)
|
|
@@ -3,24 +3,26 @@
|
|
|
3
3
|
module Smith
|
|
4
4
|
class Workflow
|
|
5
5
|
module StepCompletion
|
|
6
|
+
# Room for what completing a step adds besides its output (its state,
|
|
7
|
+
# step count, last agent).
|
|
8
|
+
RESULT_MARGIN = 1024
|
|
9
|
+
|
|
6
10
|
private
|
|
7
11
|
|
|
8
12
|
def complete_step(transition, output)
|
|
13
|
+
StepCompletion.instance_method(:check_result_storable!).bind_call(self, output)
|
|
9
14
|
step = { transition: transition.name, from: transition.from, to: transition.to, output: output }
|
|
10
15
|
# Fold in the serial agent attribution captured for this step (if any),
|
|
11
16
|
# then clear the transient carrier. record_step_snapshot reads these off
|
|
12
17
|
# the returned step to update the durable @last_agent_execution.
|
|
13
|
-
if @pending_agent_execution
|
|
14
|
-
step[:model] = @pending_agent_execution[:model]
|
|
15
|
-
step[:provider] = @pending_agent_execution[:provider]
|
|
16
|
-
end
|
|
18
|
+
step.merge!(@pending_agent_execution.slice(:model, :provider)) if @pending_agent_execution
|
|
17
19
|
|
|
18
20
|
@pending_agent_execution = nil
|
|
19
21
|
fold_pending_evaluations(step)
|
|
20
22
|
result = SplitStepPersistence
|
|
21
23
|
.instance_method(:prepare_split_step_execution_result)
|
|
22
24
|
.bind_call(self, step)
|
|
23
|
-
StepCompletion.instance_method(:
|
|
25
|
+
StepCompletion.instance_method(:keep_accepted_output).bind_call(self, transition, output)
|
|
24
26
|
SplitStepPersistence
|
|
25
27
|
.instance_method(:commit_split_step_execution_result!)
|
|
26
28
|
.bind_call(self, result)
|
|
@@ -34,10 +36,37 @@ module Smith
|
|
|
34
36
|
step
|
|
35
37
|
end
|
|
36
38
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
+
# A stored run's step whose result would take its state past
|
|
40
|
+
# persistence_max_bytes fails with PersistedStateTooLarge before the
|
|
41
|
+
# result is kept, as with any error: its usage is recorded, its
|
|
42
|
+
# on_failure taken, and its run stays storable (strict mode
|
|
43
|
+
# included), where a refused store after the step would leave it
|
|
44
|
+
# unsaved. The estimate is the state's JSON and the output's, kept
|
|
45
|
+
# twice (in the context or transcript, and as the last output).
|
|
46
|
+
def check_result_storable!(output)
|
|
47
|
+
limit = Smith.config.persistence_max_bytes
|
|
48
|
+
return unless limit && @persistence_version.to_i.positive?
|
|
49
|
+
|
|
50
|
+
bytes = JSON.generate(to_state).bytesize + (JSON.generate(output).bytesize * 2) + RESULT_MARGIN
|
|
51
|
+
raise PersistedStateTooLarge.new(key: @persistence_key, bytes:, limit:) if bytes > limit
|
|
52
|
+
rescue JSON::GeneratorError, JSON::NestingError, Encoding::UndefinedConversionError
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Once the result is captured, so a step whose result is refused leaves
|
|
57
|
+
# neither the context nor the transcript changed.
|
|
58
|
+
def keep_accepted_output(transition, output)
|
|
59
|
+
StepIO.instance_method(:deliver_output_to).bind_call(self, transition, output) if transition.output_to
|
|
60
|
+
StepCompletion.instance_method(:append_accepted_output).bind_call(self, transition, output)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The transcript keeps an accepted output unless the step writes it to
|
|
64
|
+
# the context (`output_to:`) or the session is isolated.
|
|
65
|
+
def append_accepted_output(transition, output)
|
|
66
|
+
return unless @session_messages && !output.nil? && transcript_output?(transition)
|
|
39
67
|
|
|
40
68
|
@session_messages << { role: :assistant, content: output }
|
|
69
|
+
@last_transcript_output = output
|
|
41
70
|
end
|
|
42
71
|
end
|
|
43
72
|
end
|
|
@@ -10,14 +10,17 @@ module Smith
|
|
|
10
10
|
|
|
11
11
|
def with_step_context(transition, &block)
|
|
12
12
|
ThreadContextSnapshot.new.around do
|
|
13
|
+
start_active_clock!
|
|
13
14
|
setup_step_context
|
|
14
15
|
install_step_attribution(transition)
|
|
16
|
+
install_step_idempotency_scope(transition)
|
|
15
17
|
Thread.handle_interrupt(Object => :immediate, &block)
|
|
16
18
|
rescue StandardError => e
|
|
17
19
|
@outcome = nil
|
|
18
20
|
GuardrailIntegration.instance_method(:handle_step_failure).bind_call(self, transition, e)
|
|
19
21
|
ensure
|
|
20
22
|
teardown_step_context
|
|
23
|
+
stop_active_clock!
|
|
21
24
|
end
|
|
22
25
|
ensure
|
|
23
26
|
flush_step_failure_emission
|
|
@@ -47,12 +50,17 @@ module Smith
|
|
|
47
50
|
end
|
|
48
51
|
end
|
|
49
52
|
|
|
53
|
+
# A durable composite branch runs here, in its own restored instance:
|
|
54
|
+
# it starts from what is left of the run's wall_clock, and its running
|
|
55
|
+
# time is not added back to the run's active_seconds.
|
|
50
56
|
def within_raw_step_context(&block)
|
|
51
57
|
ThreadContextSnapshot.new.around do
|
|
58
|
+
start_active_clock!
|
|
52
59
|
setup_step_context
|
|
53
60
|
Thread.handle_interrupt(Object => :immediate, &block)
|
|
54
61
|
ensure
|
|
55
62
|
teardown_step_context
|
|
63
|
+
stop_active_clock!
|
|
56
64
|
end
|
|
57
65
|
end
|
|
58
66
|
|
|
@@ -80,6 +88,19 @@ module Smith
|
|
|
80
88
|
)
|
|
81
89
|
end
|
|
82
90
|
|
|
91
|
+
# The step's position for idempotency keys: its number and transition
|
|
92
|
+
# in this run's namespace. A nested workflow sharing the namespace (an
|
|
93
|
+
# in-process child) is placed under the parent's current position, so
|
|
94
|
+
# a child step never takes a key a parent step already used; any other
|
|
95
|
+
# workflow started inside a step is a run of its own. Restoration is
|
|
96
|
+
# owned by the surrounding ThreadContextSnapshot.
|
|
97
|
+
def install_step_idempotency_scope(transition)
|
|
98
|
+
namespace = ArtifactIntegration.instance_method(:execution_namespace).bind_call(self)
|
|
99
|
+
outer = IdempotencyKey.__send__(:current_scope)
|
|
100
|
+
base = outer && outer.run == namespace ? outer : IdempotencyKey.__send__(:root_scope, namespace)
|
|
101
|
+
IdempotencyKey.__send__(:install, base.within(:step, @step_count + 1, transition.name.to_s))
|
|
102
|
+
end
|
|
103
|
+
|
|
83
104
|
def teardown_step_context
|
|
84
105
|
Tool.current_guardrails = nil
|
|
85
106
|
Tool.current_deadline = nil
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "execution_result_snapshot"
|
|
4
|
+
require_relative "message_batch"
|
|
5
|
+
require_relative "message_value_normalizer"
|
|
6
|
+
|
|
7
|
+
module Smith
|
|
8
|
+
class Workflow
|
|
9
|
+
# Per-step input and output: a step's `input:` replaces the session
|
|
10
|
+
# transcript as its messages, `output_to:` writes its output to a context
|
|
11
|
+
# key instead of the transcript, and an isolated session keeps no
|
|
12
|
+
# transcript for agents at all. Also the run's untrusted keys: its own
|
|
13
|
+
# context's, and those of every workflow it is nested in.
|
|
14
|
+
module StepIO
|
|
15
|
+
INPUT_ROLES = %w[user assistant].freeze
|
|
16
|
+
INHERITED_UNTRUSTED_KEYS = :smith_inherited_untrusted_keys
|
|
17
|
+
private_constant :INPUT_ROLES, :INHERITED_UNTRUSTED_KEYS
|
|
18
|
+
|
|
19
|
+
# Builds a nested workflow with its parent's untrusted keys in force
|
|
20
|
+
# from construction, before its seed builder runs.
|
|
21
|
+
def self.with_inherited_untrusted_keys(keys)
|
|
22
|
+
outer = Thread.current[INHERITED_UNTRUSTED_KEYS]
|
|
23
|
+
Thread.current[INHERITED_UNTRUSTED_KEYS] = untrusted_key_names!(keys)
|
|
24
|
+
yield
|
|
25
|
+
ensure
|
|
26
|
+
Thread.current[INHERITED_UNTRUSTED_KEYS] = outer
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Context key names: at most 1,000, each a Symbol or String of 1 to 256
|
|
30
|
+
# characters, as Symbols.
|
|
31
|
+
def self.untrusted_key_names!(keys)
|
|
32
|
+
valid = keys.is_a?(Array) && keys.length <= 1_000 &&
|
|
33
|
+
keys.all? { (_1.is_a?(Symbol) || _1.is_a?(String)) && _1.length.between?(1, 256) }
|
|
34
|
+
raise WorkflowError, "untrusted keys must be up to 1,000 context key names" unless valid
|
|
35
|
+
|
|
36
|
+
keys.map(&:to_sym).uniq.freeze
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The keys handed to the workflow under construction; taken once, so a
|
|
40
|
+
# workflow its constructor builds does not inherit them too.
|
|
41
|
+
def self.take_inherited_untrusted_keys
|
|
42
|
+
keys = Thread.current[INHERITED_UNTRUSTED_KEYS]
|
|
43
|
+
Thread.current[INHERITED_UNTRUSTED_KEYS] = nil
|
|
44
|
+
keys || [].freeze
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def isolated_session? = self.class.session == :isolated
|
|
50
|
+
|
|
51
|
+
def untrusted_context_keys
|
|
52
|
+
manager = self.class.context_manager
|
|
53
|
+
own = manager.respond_to?(:untrusted_keys) ? manager.untrusted_keys : []
|
|
54
|
+
own | (@inherited_untrusted_keys || [])
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The context without its untrusted keys, as `inject_state` formatters
|
|
58
|
+
# and the `seed_messages` builder read it.
|
|
59
|
+
def trusted_context_view
|
|
60
|
+
keys = untrusted_context_keys
|
|
61
|
+
keys.empty? || !@context.is_a?(Hash) ? @context : @context.except(*keys)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The messages an agent step sees: its `input:`, or else the session
|
|
65
|
+
# transcript (with injected state). An isolated session has no
|
|
66
|
+
# transcript to give: an agent step there must declare `input:`, and
|
|
67
|
+
# any other step gets nothing, so state is never injected.
|
|
68
|
+
def step_prepared_input(transition)
|
|
69
|
+
return step_input_messages(transition) if transition.step_input
|
|
70
|
+
return build_session&.prepare! unless isolated_session?
|
|
71
|
+
return unless transition.agent_step?
|
|
72
|
+
|
|
73
|
+
raise WorkflowError, "transition #{DiagnosticText.capture(transition.name.to_s, max_bytes: 256)} runs an " \
|
|
74
|
+
"agent in an isolated session and must declare input:"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# A non-empty Array of at most MessageBatch::MAX_MESSAGES user and
|
|
78
|
+
# assistant messages, bounded and JSON-shaped like session messages.
|
|
79
|
+
# The callable gets a copy of the context, so it cannot change the
|
|
80
|
+
# run's.
|
|
81
|
+
def step_input_messages(transition)
|
|
82
|
+
messages = transition.step_input.call(snapshot_value(@context))
|
|
83
|
+
unless messages.is_a?(Array) && !messages.empty? && messages.length <= MessageBatch::MAX_MESSAGES
|
|
84
|
+
raise WorkflowError, "input: must return a non-empty Array of at most #{MessageBatch::MAX_MESSAGES} messages"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
MessageValueNormalizer.new(messages, label: "step input").call.map { |message| step_input_message(message) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def step_input_message(message)
|
|
91
|
+
return step_input_parts_message(message) if Message.parts?(message)
|
|
92
|
+
unless message.is_a?(Hash) && message.keys.sort == %w[content role] && INPUT_ROLES.include?(message["role"]) &&
|
|
93
|
+
!message["content"].nil?
|
|
94
|
+
raise WorkflowError, "each input: message must be { role: :user or :assistant, content: } with content"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
{ role: message["role"].to_sym, content: message["content"] }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# A message with content parts (Smith::Message), user or assistant.
|
|
101
|
+
def step_input_parts_message(message)
|
|
102
|
+
checked = Message.check!(message)
|
|
103
|
+
raise WorkflowError, "an input: message with content parts must be a user or assistant message" unless
|
|
104
|
+
INPUT_ROLES.include?(checked["role"])
|
|
105
|
+
|
|
106
|
+
{ role: checked["role"].to_sym, content: checked["content"], parts: true }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Checked before the step runs, so a key the persist list refuses
|
|
110
|
+
# costs no agent call.
|
|
111
|
+
def writable_output_key!(transition)
|
|
112
|
+
key = transition.output_to
|
|
113
|
+
keys = declared_persist_keys
|
|
114
|
+
raise UndeclaredContextKey.new(key:, persisted_keys: keys) if keys && !keys.include?(key)
|
|
115
|
+
|
|
116
|
+
key
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The step's output, nil included, under `output_to:`: JSON-shaped and
|
|
120
|
+
# in the context's key shape, so it reads the same live and restored.
|
|
121
|
+
def deliver_output_to(transition, output)
|
|
122
|
+
key = writable_output_key!(transition)
|
|
123
|
+
value = MessageValueNormalizer.new(output, label: "output_to: value",
|
|
124
|
+
max_bytes: ExecutionResultSnapshot::MAX_BYTES).call
|
|
125
|
+
@context[key] = ContextKeys.normalize(value)
|
|
126
|
+
record_persisted_key!(key)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def transcript_output?(transition) = transition.output_to.nil? && !isolated_session?
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
# A step declared with `stream:` streams through its sink
|
|
6
|
+
# (Smith::Streaming), which hears the step's outcome: its accepted
|
|
7
|
+
# output, a durable retry's wait, or its failure. Every other step clears
|
|
8
|
+
# the stream, so none of its calls reach another step's sink; a nested
|
|
9
|
+
# workflow is a run of its own, with its own steps.
|
|
10
|
+
#
|
|
11
|
+
# The accepted output reaches the sink once the step is stored: at once
|
|
12
|
+
# for a run that is not stored (no persistence key), else when `persist!`
|
|
13
|
+
# stores it, or when the run moves on without storing it. A store that
|
|
14
|
+
# fails tells the sink the step failed, with the store's error.
|
|
15
|
+
module StepStream
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def with_step_stream(transition)
|
|
19
|
+
@step_stream = transition.stream &&
|
|
20
|
+
Streaming.open(transition.stream, execution_key: @persistence_key, transition: transition.name)
|
|
21
|
+
step = yield
|
|
22
|
+
if wait_pending?(step) then @step_stream&.suspend
|
|
23
|
+
elsif @step_stream then accept_streamed(@step_stream, step[:output])
|
|
24
|
+
end
|
|
25
|
+
step
|
|
26
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- the sink hears of every failure, then it goes on
|
|
27
|
+
@step_stream&.fail(e.class)
|
|
28
|
+
raise
|
|
29
|
+
ensure
|
|
30
|
+
@step_stream = nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def accept_streamed(session, output)
|
|
34
|
+
return session.finish(output) unless @persistence_key
|
|
35
|
+
|
|
36
|
+
@streamed_output = [session, output]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def deliver_streamed_output
|
|
40
|
+
session, output = @streamed_output
|
|
41
|
+
@streamed_output = nil
|
|
42
|
+
session&.finish(output)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def discard_streamed_output(error_class)
|
|
46
|
+
session, = @streamed_output
|
|
47
|
+
@streamed_output = nil
|
|
48
|
+
session&.fail(error_class)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -20,6 +20,7 @@ module Smith
|
|
|
20
20
|
current_invocation_context
|
|
21
21
|
current_invocation_sequence
|
|
22
22
|
current_invocation
|
|
23
|
+
current_idempotency_scope
|
|
23
24
|
].freeze
|
|
24
25
|
THREAD_KEYS = %i[
|
|
25
26
|
smith_attribution
|
|
@@ -28,6 +29,7 @@ module Smith
|
|
|
28
29
|
smith_failed_agent_results
|
|
29
30
|
smith_last_agent_result
|
|
30
31
|
smith_parallel_agent_binding
|
|
32
|
+
smith_step_depth
|
|
31
33
|
].freeze
|
|
32
34
|
|
|
33
35
|
option :tool_attributes, default: proc { TOOL_ATTRIBUTES }
|