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
|
@@ -4,6 +4,10 @@ module Smith
|
|
|
4
4
|
class Workflow
|
|
5
5
|
class Graph
|
|
6
6
|
class TransitionDiagnostics
|
|
7
|
+
TARGET_ATTRIBUTES = %i[success_transition failure_transition timeout_transition].freeze
|
|
8
|
+
DECLARATION_CHECKS = [
|
|
9
|
+
BranchLimitDiagnostic, RetryPolicyDiagnostic, IsolatedInputDiagnostic, OutputKeyDiagnostic
|
|
10
|
+
].freeze
|
|
7
11
|
attr_reader :graph
|
|
8
12
|
|
|
9
13
|
def initialize(graph)
|
|
@@ -13,13 +17,11 @@ module Smith
|
|
|
13
17
|
def to_a
|
|
14
18
|
graph.transitions.values.flat_map do |transition|
|
|
15
19
|
[
|
|
16
|
-
transition_target_diagnostic(transition,
|
|
17
|
-
transition_target_diagnostic(transition, :failure_transition, transition.failure_transition),
|
|
20
|
+
*TARGET_ATTRIBUTES.map { transition_target_diagnostic(transition, _1, transition.public_send(_1)) },
|
|
18
21
|
*router_target_diagnostics(transition),
|
|
19
22
|
*deterministic_route_diagnostics(transition),
|
|
20
23
|
*target_state_mismatch_diagnostics(transition),
|
|
21
|
-
|
|
22
|
-
RetryPolicyDiagnostic.new(transition:).call
|
|
24
|
+
*declaration_diagnostics(transition)
|
|
23
25
|
].compact
|
|
24
26
|
end
|
|
25
27
|
end
|
|
@@ -63,29 +65,14 @@ module Smith
|
|
|
63
65
|
transition.failure_transition,
|
|
64
66
|
expected_state: transition.from
|
|
65
67
|
)
|
|
68
|
+
timeout = target_state_mismatch_diagnostic(transition, transition.timeout_transition,
|
|
69
|
+
expected_state: transition.from)
|
|
66
70
|
|
|
67
|
-
[*success, failure].compact
|
|
71
|
+
[*success, failure, timeout].compact
|
|
68
72
|
end
|
|
69
73
|
|
|
70
|
-
def
|
|
71
|
-
|
|
72
|
-
limit = Smith.config.parallel_branch_limit
|
|
73
|
-
return unless branch_count && branch_count > limit
|
|
74
|
-
|
|
75
|
-
Diagnostic.new(
|
|
76
|
-
severity: :error,
|
|
77
|
-
code: :parallel_branch_limit_exceeded,
|
|
78
|
-
transition: transition.name,
|
|
79
|
-
message: "Transition #{ref(transition.name)} declares #{branch_count} parallel branches, " \
|
|
80
|
-
"exceeding the configured limit #{limit}.",
|
|
81
|
-
suggestion: "Reduce the branch count or explicitly raise Smith.config.parallel_branch_limit."
|
|
82
|
-
)
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
def static_branch_count(transition)
|
|
86
|
-
return transition.fanout_config.fetch(:branches).length if transition.fanout?
|
|
87
|
-
|
|
88
|
-
transition.parallel_count if transition.parallel?
|
|
74
|
+
def declaration_diagnostics(transition)
|
|
75
|
+
DECLARATION_CHECKS.map { _1.new(transition:, workflow_class: graph.workflow_class).call }
|
|
89
76
|
end
|
|
90
77
|
|
|
91
78
|
def successful_target_names(transition)
|
|
@@ -29,14 +29,23 @@ module Smith
|
|
|
29
29
|
option :parallel_count, default: proc {}
|
|
30
30
|
option :success_transition, default: proc {}
|
|
31
31
|
option :failure_transition, default: proc {}
|
|
32
|
+
option :timeout_transition, default: proc {}
|
|
32
33
|
option :routes, default: proc {}
|
|
33
34
|
option :fallback, default: proc {}
|
|
34
35
|
option :deterministic_routes, default: proc {}
|
|
36
|
+
option :deterministic_fallthrough, default: proc {}
|
|
35
37
|
option :fanout_branches, default: proc {}
|
|
36
38
|
option :fanout, default: proc {}
|
|
37
39
|
option :optimization, default: proc {}
|
|
38
40
|
option :orchestration, default: proc {}
|
|
39
41
|
option :retry_policy, default: proc {}
|
|
42
|
+
option :wait, default: proc {}
|
|
43
|
+
option :input, default: proc {}
|
|
44
|
+
option :output_to, default: proc {}
|
|
45
|
+
option :stream, default: proc {}
|
|
46
|
+
option :mode, default: proc {}
|
|
47
|
+
option :on_request_change, default: proc {}
|
|
48
|
+
option :children, default: proc {}
|
|
40
49
|
|
|
41
50
|
def self.from_transition(transition, workflow_class: nil)
|
|
42
51
|
new(**attributes_for(transition, workflow_class: workflow_class))
|
|
@@ -56,48 +65,56 @@ module Smith
|
|
|
56
65
|
{
|
|
57
66
|
success_transition: transition.success_transition,
|
|
58
67
|
failure_transition: transition.failure_transition,
|
|
68
|
+
timeout_transition: transition.timeout_transition,
|
|
59
69
|
routes: transition.router_config&.fetch(:routes, nil),
|
|
60
70
|
fallback: transition.router_config&.fetch(:fallback, nil),
|
|
61
71
|
deterministic_routes: transition.deterministic_routes,
|
|
72
|
+
deterministic_fallthrough: transition.deterministic_routes && transition.deterministic_fallthrough == true,
|
|
62
73
|
fanout_branches: transition.fanout_config&.fetch(:branches, nil)
|
|
63
74
|
}
|
|
64
75
|
end
|
|
65
76
|
|
|
66
77
|
def self.contract_attributes(transition, workflow_class:)
|
|
67
78
|
{
|
|
68
|
-
fanout:
|
|
69
|
-
optimization:
|
|
70
|
-
orchestration:
|
|
71
|
-
retry_policy: retry_policy_for(transition)
|
|
79
|
+
fanout: FanoutContract.from_transition(transition, workflow_class:),
|
|
80
|
+
optimization: OptimizationContract.from_transition(transition, workflow_class:),
|
|
81
|
+
orchestration: OrchestrationContract.from_transition(transition, workflow_class:),
|
|
82
|
+
retry_policy: retry_policy_for(transition),
|
|
83
|
+
wait: transition.await_config,
|
|
84
|
+
input: (:callable if transition.step_input),
|
|
85
|
+
output_to: transition.output_to,
|
|
86
|
+
stream: transition.stream,
|
|
87
|
+
mode: transition.batch_mode,
|
|
88
|
+
on_request_change: transition.on_request_change,
|
|
89
|
+
children: children_for(transition)
|
|
72
90
|
}
|
|
73
91
|
end
|
|
74
92
|
|
|
75
|
-
def self.
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def self.optimization_for(transition, workflow_class:)
|
|
80
|
-
OptimizationContract.from_transition(transition, workflow_class: workflow_class)
|
|
81
|
-
end
|
|
93
|
+
def self.retry_policy_for(transition)
|
|
94
|
+
config = transition.retry_config
|
|
95
|
+
return unless config
|
|
82
96
|
|
|
83
|
-
|
|
84
|
-
|
|
97
|
+
new_key_classes = config.fetch(:new_idempotency_key_on, [])
|
|
98
|
+
{ attempts: config.fetch(:attempts), error_classes: config.fetch(:error_classes).map(&:name),
|
|
99
|
+
backoff: config.fetch(:backoff), max_delay: config[:max_delay], jitter: config.fetch(:jitter),
|
|
100
|
+
new_idempotency_key_on: (new_key_classes.map(&:name) if new_key_classes.any?),
|
|
101
|
+
durable: (true if config[:durable]) }.compact
|
|
85
102
|
end
|
|
86
103
|
|
|
87
|
-
|
|
88
|
-
|
|
104
|
+
# A map, call or spawn: its child workflow (by name), bound, collect
|
|
105
|
+
# and failure modes, and its items (a context key, or :callable).
|
|
106
|
+
def self.children_for(transition)
|
|
107
|
+
config = transition.children_config
|
|
89
108
|
return unless config
|
|
90
109
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
error_classes: config.fetch(:error_classes).map(&:name),
|
|
94
|
-
backoff: config.fetch(:backoff),
|
|
95
|
-
max_delay: config[:max_delay],
|
|
96
|
-
jitter: config.fetch(:jitter)
|
|
97
|
-
}.compact
|
|
110
|
+
workflow = config[:workflow]
|
|
111
|
+
config.merge(workflow: workflow.name || workflow.inspect)
|
|
98
112
|
end
|
|
99
113
|
|
|
100
114
|
def self.kind_for(transition)
|
|
115
|
+
return transition.children_config.fetch(:kind) if transition.child_runs?
|
|
116
|
+
return transition.await_config.fetch(:kind) if transition.awaits?
|
|
117
|
+
|
|
101
118
|
kind = KINDS.find { |_name, predicate| transition.public_send(predicate) }
|
|
102
119
|
return kind.first if kind
|
|
103
120
|
return :agent if transition.agent_name
|
|
@@ -106,23 +123,7 @@ module Smith
|
|
|
106
123
|
end
|
|
107
124
|
|
|
108
125
|
def to_h
|
|
109
|
-
|
|
110
|
-
name: name,
|
|
111
|
-
from: from,
|
|
112
|
-
to: to,
|
|
113
|
-
kind: kind,
|
|
114
|
-
parallel_count: parallel_count,
|
|
115
|
-
success_transition: success_transition,
|
|
116
|
-
failure_transition: failure_transition,
|
|
117
|
-
routes: routes,
|
|
118
|
-
fallback: fallback,
|
|
119
|
-
deterministic_routes: deterministic_routes,
|
|
120
|
-
fanout_branches: fanout_branches,
|
|
121
|
-
fanout: fanout,
|
|
122
|
-
optimization: optimization,
|
|
123
|
-
orchestration: orchestration,
|
|
124
|
-
retry_policy: retry_policy
|
|
125
|
-
}.compact
|
|
126
|
+
self.class.dry_initializer.attributes(self).compact
|
|
126
127
|
end
|
|
127
128
|
end
|
|
128
129
|
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Workflow
|
|
7
|
+
class Graph
|
|
8
|
+
# An agent that names a context key the workflow's context marks
|
|
9
|
+
# untrusted among its `inputs` is refused at invocation.
|
|
10
|
+
class UntrustedInputDiagnostic
|
|
11
|
+
extend Dry::Initializer
|
|
12
|
+
|
|
13
|
+
param :graph
|
|
14
|
+
option :binding
|
|
15
|
+
|
|
16
|
+
# The context keys an agent reads as inputs: its declared `inputs`,
|
|
17
|
+
# without the reserved names Smith never reads from the context.
|
|
18
|
+
def self.context_inputs(agent_name)
|
|
19
|
+
agent_class = Agent::Registry.binding_for(agent_name)&.fetch(:agent_class)
|
|
20
|
+
inputs = agent_class.respond_to?(:inputs) ? Array(agent_class.inputs) : []
|
|
21
|
+
inputs - Agent::RESERVED_INPUT_NAMES
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The untrusted context keys a workflow class declares.
|
|
25
|
+
def self.declared_keys(workflow_class)
|
|
26
|
+
manager = workflow_class.context_manager if workflow_class.respond_to?(:context_manager)
|
|
27
|
+
manager.respond_to?(:untrusted_keys) ? manager.untrusted_keys : []
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.suggestion(key)
|
|
31
|
+
"Pass #{Reference.format(key)} through the step's input:, wrapped with Smith::Untrusted.wrap."
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def call
|
|
35
|
+
key = untrusted_input
|
|
36
|
+
return unless key
|
|
37
|
+
|
|
38
|
+
Diagnostic.new(
|
|
39
|
+
severity: :error, code: :untrusted_agent_input, transition: binding.fetch(:transition).name,
|
|
40
|
+
target: binding.fetch(:agent), message: message(key), suggestion: self.class.suggestion(key)
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def untrusted_input
|
|
45
|
+
(self.class.context_inputs(binding.fetch(:agent)) & self.class.declared_keys(graph.workflow_class)).first
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def message(key)
|
|
49
|
+
role = binding.fetch(:role)
|
|
50
|
+
"#{role[0].upcase}#{role[1..]} #{Reference.format(binding.fetch(:agent))} on transition " \
|
|
51
|
+
"#{Reference.format(binding.fetch(:transition).name)} declares untrusted context key " \
|
|
52
|
+
"#{Reference.format(key)} as an input."
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
class Graph
|
|
6
|
+
# An in-process child (a nested workflow, or a child run the launcher
|
|
7
|
+
# runs in process) that reaches a step that waits, or a nested
|
|
8
|
+
# workflow that launches runs of its own.
|
|
9
|
+
module WaitingChildDiagnostic
|
|
10
|
+
def self.build(transition, label)
|
|
11
|
+
runs = transition.child_runs?
|
|
12
|
+
verb = runs ? "starts in-process runs of" : "nests"
|
|
13
|
+
Diagnostic.new(
|
|
14
|
+
severity: :error, code: :nested_workflow_waits, transition: transition.name, target: label,
|
|
15
|
+
message: "Transition #{Reference.format(transition.name)} #{verb} #{label}, " \
|
|
16
|
+
"which reaches a step that waits#{" or launches runs of its own" unless runs}.",
|
|
17
|
+
suggestion: "An in-process child cannot wait#{" or launch" unless runs}; move the step into the parent " \
|
|
18
|
+
"workflow#{", or use a launcher that queues children" if runs}."
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
data/lib/smith/workflow/graph.rb
CHANGED
|
@@ -93,6 +93,7 @@ require_relative "graph/execution_successors"
|
|
|
93
93
|
require_relative "graph/reachability"
|
|
94
94
|
require_relative "graph/reachability_diagnostics"
|
|
95
95
|
require_relative "graph/metrics"
|
|
96
|
+
require_relative "graph/inherited_untrusted_diagnostics"
|
|
96
97
|
require_relative "graph/nested_readiness_diagnostics"
|
|
97
98
|
require_relative "graph/report"
|
|
98
99
|
require_relative "graph/runtime_binding_diagnostic_builder"
|
|
@@ -104,6 +105,17 @@ require_relative "graph/optimization_contract"
|
|
|
104
105
|
require_relative "graph/orchestration_contract"
|
|
105
106
|
require_relative "graph/transition_snapshot"
|
|
106
107
|
require_relative "graph/retry_policy_diagnostic"
|
|
108
|
+
require_relative "graph/branch_limit_diagnostic"
|
|
109
|
+
require_relative "graph/isolated_input_diagnostic"
|
|
110
|
+
require_relative "graph/output_key_diagnostic"
|
|
111
|
+
require_relative "graph/stream_diagnostic"
|
|
112
|
+
require_relative "graph/batch_diagnostic"
|
|
113
|
+
require_relative "graph/budget_ledger_diagnostic"
|
|
114
|
+
require_relative "graph/untrusted_input_diagnostic"
|
|
115
|
+
require_relative "graph/output_schema_diagnostic"
|
|
116
|
+
require_relative "graph/run_launcher_diagnostic"
|
|
117
|
+
require_relative "graph/waiting_child_diagnostic"
|
|
118
|
+
require_relative "graph/durable_retry_diagnostic"
|
|
107
119
|
require_relative "graph/transition_diagnostics"
|
|
108
120
|
require_relative "graph/validator"
|
|
109
121
|
require_relative "graph/runtime_readiness"
|
|
@@ -14,26 +14,71 @@ module Smith
|
|
|
14
14
|
begin
|
|
15
15
|
Thread.handle_interrupt(Object => :immediate) { run_tracked_guarded_step(transition) }
|
|
16
16
|
rescue StandardError => e
|
|
17
|
-
raise unless tracker
|
|
18
|
-
raise if terminal_retry_error?(e)
|
|
17
|
+
raise unless uncertain_after_tools?(tracker, e)
|
|
19
18
|
|
|
20
19
|
raise ToolOutcomeUncertain.new(
|
|
21
20
|
"transition failed after tool execution began; retry could replay an uncertain outcome"
|
|
22
21
|
), cause: e
|
|
23
22
|
ensure
|
|
23
|
+
# Tool evidence travels up: a step running inside another step
|
|
24
|
+
# (a nested workflow's step) marks the enclosing step too, so the
|
|
25
|
+
# enclosing step is never retried over tools that already ran.
|
|
26
|
+
previous_tracker&.mark_started! if tracker.started?
|
|
24
27
|
Tool.current_tool_execution_tracker = previous_tracker
|
|
25
28
|
end
|
|
26
29
|
end
|
|
27
30
|
end
|
|
28
31
|
|
|
32
|
+
# A failure after a tool ran leaves its outcome uncertain, unless its
|
|
33
|
+
# retry could not run the tool again: a terminal failure, or a tool
|
|
34
|
+
# loop paused for budget, whose retry continues from its tools'
|
|
35
|
+
# results.
|
|
36
|
+
def uncertain_after_tools?(tracker, error)
|
|
37
|
+
tracker.started? && !terminal_retry_error?(error) &&
|
|
38
|
+
!(error.is_a?(BudgetUnavailable) && error.continuation)
|
|
39
|
+
end
|
|
40
|
+
|
|
29
41
|
def run_tracked_guarded_step(transition)
|
|
30
42
|
return apply_composite_reduction!(transition) if @composite_reduction
|
|
31
43
|
|
|
32
44
|
@resolved_parallel_branch_count = preflight_branch_count(transition)
|
|
33
|
-
|
|
45
|
+
context_before = @context.dup
|
|
46
|
+
output = run_standard_guarded_step(transition)
|
|
47
|
+
enforce_context_shape!(context_before)
|
|
48
|
+
output
|
|
34
49
|
ensure
|
|
35
50
|
@resolved_parallel_branch_count = nil
|
|
36
51
|
end
|
|
52
|
+
|
|
53
|
+
# Hooks that receive the live context (`before_eval`,
|
|
54
|
+
# `after_completion`, guardrails) can write to it directly. At the end
|
|
55
|
+
# of the step every new top-level key must be a Symbol that an explicit
|
|
56
|
+
# `persist` list allows, and every new or replaced value takes the
|
|
57
|
+
# context's key shape. On a violation the context goes back to its
|
|
58
|
+
# top-level values from the start of the step, as a failed
|
|
59
|
+
# deterministic step keeps none of its writes. A value changed in place
|
|
60
|
+
# is not seen until the next restore normalizes it. `O(K)` plus the size
|
|
61
|
+
# of changed values.
|
|
62
|
+
def enforce_context_shape!(before)
|
|
63
|
+
allowed = declared_persist_keys
|
|
64
|
+
normalized = @context.each_with_object({}) do |(key, value), changed|
|
|
65
|
+
next if before.key?(key) && before[key].equal?(value)
|
|
66
|
+
|
|
67
|
+
admit_context_key!(key, new_key: !before.key?(key), allowed:)
|
|
68
|
+
changed[key] = ContextKeys.normalize(value)
|
|
69
|
+
end
|
|
70
|
+
@context.merge!(normalized)
|
|
71
|
+
rescue WorkflowError
|
|
72
|
+
@context.replace(before)
|
|
73
|
+
raise
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def admit_context_key!(key, new_key:, allowed:)
|
|
77
|
+
ContextKeys.validate_top_level_key!(key)
|
|
78
|
+
return unless new_key && allowed && !allowed.include?(key)
|
|
79
|
+
|
|
80
|
+
raise UndeclaredContextKey.new(key:, persisted_keys: allowed)
|
|
81
|
+
end
|
|
37
82
|
end
|
|
38
83
|
end
|
|
39
84
|
end
|
|
@@ -24,6 +24,8 @@ module Smith
|
|
|
24
24
|
|
|
25
25
|
def handle_step_failure(transition, error)
|
|
26
26
|
step = { transition: transition.name, from: transition.from, to: transition.to, error: error }
|
|
27
|
+
fold_pending_evaluations(step)
|
|
28
|
+
@pending_evaluations = nil
|
|
27
29
|
# Staged, not emitted: this rescue runs under the step snapshot's
|
|
28
30
|
# interrupt mask, and host StepFailed handlers must not execute
|
|
29
31
|
# unkillable. with_step_context flushes after the mask closes.
|
|
@@ -4,6 +4,7 @@ require "dry-initializer"
|
|
|
4
4
|
|
|
5
5
|
require_relative "../errors"
|
|
6
6
|
require_relative "message_admission"
|
|
7
|
+
require_relative "message_roles"
|
|
7
8
|
|
|
8
9
|
module Smith
|
|
9
10
|
class Workflow
|
|
@@ -50,6 +51,11 @@ module Smith
|
|
|
50
51
|
role = message["role"]
|
|
51
52
|
reject!("each session message requires a non-empty role") unless
|
|
52
53
|
role.is_a?(String) && !role.strip.empty?
|
|
54
|
+
reject!("session messages admitted by the host cannot have the system role") if role.strip.casecmp?("system")
|
|
55
|
+
reject!("session messages admitted by the host cannot carry content parts") if Message.parts?(message)
|
|
56
|
+
return if MessageRoles.role(message, MessageRoles::ADMITTED)
|
|
57
|
+
|
|
58
|
+
reject!("session messages admitted by the host must have the role user, assistant or tool")
|
|
53
59
|
end
|
|
54
60
|
|
|
55
61
|
def reject!(message)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
# The roles a session message may carry, matched exactly. A host admits
|
|
6
|
+
# user, assistant and tool messages; system messages come only from
|
|
7
|
+
# Smith (injected state) and the host's `seed_messages` builder.
|
|
8
|
+
module MessageRoles
|
|
9
|
+
ALL = %w[system user assistant tool].freeze
|
|
10
|
+
ADMITTED = %w[user assistant tool].freeze
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# The role of a message Hash with String or Symbol keys, when it is
|
|
15
|
+
# one of `allowed`; nil otherwise.
|
|
16
|
+
def role(message, allowed = ALL)
|
|
17
|
+
return unless message.is_a?(Hash)
|
|
18
|
+
|
|
19
|
+
value = message.key?(:role) ? message[:role] : message["role"]
|
|
20
|
+
value = value.to_s if value.is_a?(Symbol)
|
|
21
|
+
value if value.is_a?(String) && allowed.include?(value)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -20,6 +20,7 @@ module Smith
|
|
|
20
20
|
|
|
21
21
|
param :value
|
|
22
22
|
option :label, default: proc { "session message" }
|
|
23
|
+
option :max_bytes, default: proc { MAX_BYTES }
|
|
23
24
|
|
|
24
25
|
def call
|
|
25
26
|
@active = {}.compare_by_identity
|
|
@@ -100,7 +101,7 @@ module Smith
|
|
|
100
101
|
|
|
101
102
|
def copy_string(string)
|
|
102
103
|
@bytes += StringSnapshot.bytesize(string)
|
|
103
|
-
reject!("#{label} exceeds maximum bytes #{
|
|
104
|
+
reject!("#{label} exceeds maximum bytes #{max_bytes}") if @bytes > max_bytes
|
|
104
105
|
|
|
105
106
|
StringSnapshot.copy(string, freeze: true)
|
|
106
107
|
end
|
|
@@ -11,18 +11,55 @@ module Smith
|
|
|
11
11
|
check_deadline!
|
|
12
12
|
child = build_child_workflow(transition.workflow_class)
|
|
13
13
|
child_result = run_child_workflow(child)
|
|
14
|
+
reject_waiting_child!(child, child_result)
|
|
14
15
|
handle_child_result(child_result)
|
|
15
16
|
end
|
|
16
17
|
|
|
18
|
+
# A child that raises passes its error to the parent unchanged, after
|
|
19
|
+
# its billable usage is rolled up. If a tool ran in the child, the
|
|
20
|
+
# parent's step already carries that evidence and turns the failure
|
|
21
|
+
# into ToolOutcomeUncertain (GuardedStepExecution).
|
|
17
22
|
def run_child_workflow(child)
|
|
18
23
|
child.run!
|
|
19
|
-
rescue
|
|
20
|
-
|
|
24
|
+
rescue StandardError
|
|
25
|
+
roll_up_child_totals(raised_child_usage(child))
|
|
26
|
+
raise
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# An in-process child cannot be left waiting inside its parent's step:
|
|
30
|
+
# its usage is kept, and the parent's step fails.
|
|
31
|
+
def reject_waiting_child!(child, child_result)
|
|
32
|
+
return unless child.waiting?
|
|
33
|
+
|
|
34
|
+
roll_up_child_totals(child_result)
|
|
35
|
+
cause = RetryWait.instance_method(:retry_wait_error).bind_call(child)
|
|
36
|
+
raise WorkflowError, "a nested workflow reached a step that waits (an await, delay or durable retry); " \
|
|
37
|
+
"an in-process child cannot wait", cause:
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
RaisedChildUsage = Struct.new(:usage_entries, :total_cost, :total_tokens)
|
|
41
|
+
private_constant :RaisedChildUsage
|
|
42
|
+
|
|
43
|
+
def raised_child_usage(child)
|
|
44
|
+
RaisedChildUsage.new(child.usage_entries, child.instance_variable_get(:@total_cost),
|
|
45
|
+
child.instance_variable_get(:@total_tokens))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# A nested workflow inherits the run's untrusted keys, and charges the
|
|
49
|
+
# run's budget ledger unless it names its own.
|
|
50
|
+
def new_nested_workflow(child_class)
|
|
51
|
+
StepIO.with_inherited_untrusted_keys(untrusted_context_keys) do
|
|
52
|
+
Budget::HostLedger.inheriting(method(:run_budget_ledger)) do
|
|
53
|
+
ResponseMemo.inheriting([*@memo_attempts, @step_attempt.to_i]) do
|
|
54
|
+
child_class.new(context: @context.dup, ledger: @ledger, created_at: @created_at)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
21
58
|
end
|
|
22
59
|
|
|
23
60
|
def build_child_workflow(child_class)
|
|
24
61
|
@split_step_active_execution_authorization&.verify_workflow!(child_class)
|
|
25
|
-
child = child_class
|
|
62
|
+
child = new_nested_workflow(child_class)
|
|
26
63
|
unless child.instance_of?(child_class)
|
|
27
64
|
raise WorkflowError, "nested workflow constructor returned #{child.class} instead of #{child_class}"
|
|
28
65
|
end
|
|
@@ -49,8 +86,14 @@ module Smith
|
|
|
49
86
|
def handle_child_result(child_result)
|
|
50
87
|
roll_up_child_totals(child_result)
|
|
51
88
|
|
|
89
|
+
# A child that handled its failure through its own `on_failure` route
|
|
90
|
+
# has already applied its failure policy, so the parent sees a
|
|
91
|
+
# non-retryable WorkflowError, as before.
|
|
52
92
|
failed_step = child_result.steps.find { |s| s.key?(:error) }
|
|
53
|
-
|
|
93
|
+
if failed_step
|
|
94
|
+
raise WorkflowError,
|
|
95
|
+
"nested workflow failed: #{DiagnosticText.capture(failed_step[:error]&.message.to_s, max_bytes: 1024)}"
|
|
96
|
+
end
|
|
54
97
|
|
|
55
98
|
child_result.output
|
|
56
99
|
end
|
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
module Smith
|
|
4
4
|
class Workflow
|
|
5
5
|
OptimizationState = Struct.new(
|
|
6
|
-
:config, :prepared_input, :candidate, :feedback, :last_score, :generator_class, :evaluator_class
|
|
6
|
+
:config, :prepared_input, :candidate, :feedback, :last_score, :generator_class, :evaluator_class, :evaluations
|
|
7
7
|
) do
|
|
8
8
|
def initialize(config, prepared_input)
|
|
9
|
-
super(config, prepared_input, nil, nil, nil, nil, nil)
|
|
9
|
+
super(config, prepared_input, nil, nil, nil, nil, nil, [].freeze)
|
|
10
10
|
end
|
|
11
11
|
end
|
|
12
12
|
end
|
|
@@ -40,7 +40,7 @@ module Smith
|
|
|
40
40
|
|
|
41
41
|
def run_orchestration_loop(state)
|
|
42
42
|
state.config[:max_delegation_rounds].times do |round|
|
|
43
|
-
result = run_orchestration_round(state, round)
|
|
43
|
+
result = IdempotencyKey.__send__(:within, :round, round) { run_orchestration_round(state, round) }
|
|
44
44
|
return result if result
|
|
45
45
|
end
|
|
46
46
|
|
|
@@ -65,12 +65,18 @@ module Smith
|
|
|
65
65
|
invoke_agent_with_budget(state.orchestrator_class, input)
|
|
66
66
|
end
|
|
67
67
|
|
|
68
|
+
# A task's position is its content, not its place in the list: a
|
|
69
|
+
# replayed orchestrator may list the same tasks in another order.
|
|
68
70
|
def execute_workers(state, tasks)
|
|
69
71
|
runner = method(:run_worker_with_schema)
|
|
72
|
+
output_schema = state.config[:worker_output_schema]
|
|
73
|
+
copies = Hash.new(0)
|
|
70
74
|
tasks.map do |task|
|
|
71
75
|
validate_task!(task, state.config[:task_schema])
|
|
72
|
-
execution =
|
|
73
|
-
|
|
76
|
+
execution = IdempotencyKey.__send__(:within_content, :task, task, copies) do
|
|
77
|
+
WorkerExecution.run(state.worker_class, task, output_schema, runner)
|
|
78
|
+
end
|
|
79
|
+
validate_worker_output!(execution.output, output_schema)
|
|
74
80
|
{ execution_id: execution.execution_id, task: execution.task, output: execution.output }
|
|
75
81
|
end
|
|
76
82
|
end
|
|
@@ -36,6 +36,7 @@ module Smith
|
|
|
36
36
|
def run_branch(transition, index, env, ledger, signal)
|
|
37
37
|
binding = ParallelAgentBinding.new(self, transition, env.agent_class)
|
|
38
38
|
with_branch_context(env, ledger, parallel_agent_binding: binding) do
|
|
39
|
+
narrow_idempotency_scope(:branch, index)
|
|
39
40
|
with_agent_context(env.agent_class) do
|
|
40
41
|
branch_ledger = effective_call_ledger
|
|
41
42
|
reserved = reserve_branch_call(branch_ledger, env, ledger)
|
|
@@ -74,6 +75,7 @@ module Smith
|
|
|
74
75
|
# Restoration is owned by the snapshot above, which tracks the
|
|
75
76
|
# attribution thread key.
|
|
76
77
|
Attribution.install(Attribution.ambient.merge(**attribution_overlay)) if attribution_overlay
|
|
78
|
+
narrow_idempotency_scope(:branch, attribution_overlay[:branch_key]) if attribution_overlay&.key?(:branch_key)
|
|
77
79
|
|
|
78
80
|
unless parallel_agent_binding.equal?(NO_PARALLEL_BINDING)
|
|
79
81
|
Thread.current[:smith_parallel_agent_binding] = parallel_agent_binding
|
|
@@ -84,6 +86,14 @@ module Smith
|
|
|
84
86
|
end
|
|
85
87
|
end
|
|
86
88
|
|
|
89
|
+
# Each branch of a step is a position of its own, so the same request
|
|
90
|
+
# from two branches gets two keys. Restoration is owned by the
|
|
91
|
+
# branch's ThreadContextSnapshot.
|
|
92
|
+
def narrow_idempotency_scope(*segment)
|
|
93
|
+
scope = IdempotencyKey.__send__(:current_scope)
|
|
94
|
+
IdempotencyKey.__send__(:install, scope.within(*segment)) if scope
|
|
95
|
+
end
|
|
96
|
+
|
|
87
97
|
def setup_branch_context(env, ledger)
|
|
88
98
|
env.setup_thread
|
|
89
99
|
Tool.current_ledger = ledger
|