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
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Workflow
|
|
7
|
+
class Graph
|
|
8
|
+
# A step that may send its requests as a batch (`mode:`) needs
|
|
9
|
+
# Smith.config.batch_queue, and agents whose calls can go as one: a
|
|
10
|
+
# model whose provider has a batch interface in Smith (Anthropic's) and
|
|
11
|
+
# no tools (a batch request is one turn). Errors for `mode: :batch`;
|
|
12
|
+
# warnings for a mode callable, which may choose :sync, and for a
|
|
13
|
+
# model or tools given by a block, known only per run.
|
|
14
|
+
class BatchDiagnostic
|
|
15
|
+
extend Dry::Initializer
|
|
16
|
+
|
|
17
|
+
param :graph
|
|
18
|
+
|
|
19
|
+
def to_a
|
|
20
|
+
graph.reachable_transitions.select(&:batches?).flat_map do |transition|
|
|
21
|
+
severity = transition.batch_mode == :batch ? :error : :warning
|
|
22
|
+
[*queue_missing(transition), *agents(transition).filter_map { unavailable(transition, _1, severity) }]
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def agents(transition)
|
|
29
|
+
return transition.fanout_config.fetch(:branches).values if transition.fanout?
|
|
30
|
+
|
|
31
|
+
[transition.agent_name]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def queue_missing(transition)
|
|
35
|
+
return [] if Smith.config.batch_queue
|
|
36
|
+
|
|
37
|
+
[Diagnostic.new(
|
|
38
|
+
severity: :error, code: :batch_queue_missing, transition: transition.name,
|
|
39
|
+
message: "Transition #{Reference.format(transition.name)} may send its requests as a batch, which goes " \
|
|
40
|
+
"through Smith.config.batch_queue, and none is configured.",
|
|
41
|
+
suggestion: "Set Smith.config.batch_queue to the host's queue (enqueue(custom_id:, await_token:, " \
|
|
42
|
+
"request:, execution_key:))."
|
|
43
|
+
)]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def unavailable(transition, agent, severity)
|
|
47
|
+
agent_class = Agent::Registry.binding_for(agent)&.fetch(:agent_class)
|
|
48
|
+
return unless agent_class.respond_to?(:chat_kwargs)
|
|
49
|
+
|
|
50
|
+
reason = unavailable_reason(agent_class)
|
|
51
|
+
return unless reason
|
|
52
|
+
|
|
53
|
+
severity = :warning if agent_class.model_block || agent_class.tools.is_a?(Proc)
|
|
54
|
+
Diagnostic.new(
|
|
55
|
+
severity:, code: :batch_unavailable, transition: transition.name, target: agent,
|
|
56
|
+
message: "Transition #{Reference.format(transition.name)} may batch agent #{Reference.format(agent)}, " \
|
|
57
|
+
"#{reason}.",
|
|
58
|
+
suggestion: "Give the agent an Anthropic model and no tools, or keep the step synchronous."
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def unavailable_reason(agent_class)
|
|
63
|
+
tools = agent_class.respond_to?(:tools) ? agent_class.tools : []
|
|
64
|
+
return "which has tools (a batch request is one turn)" unless tools.respond_to?(:empty?) && tools.empty?
|
|
65
|
+
|
|
66
|
+
provider = provider_of(agent_class)
|
|
67
|
+
"whose model's provider (#{provider || "unknown"}) has no batch interface in Smith" unless
|
|
68
|
+
provider == "anthropic"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def provider_of(agent_class)
|
|
72
|
+
model, provider = agent_class.chat_kwargs.values_at(:model, :provider)
|
|
73
|
+
return provider.to_s if provider
|
|
74
|
+
return unless model
|
|
75
|
+
|
|
76
|
+
Smith::Models.find_or_infer(model)&.provider&.to_s
|
|
77
|
+
rescue StandardError
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Workflow
|
|
7
|
+
class Graph
|
|
8
|
+
# A parallel or fan-out step that declares more branches than
|
|
9
|
+
# Smith.config.parallel_branch_limit allows.
|
|
10
|
+
class BranchLimitDiagnostic
|
|
11
|
+
extend Dry::Initializer
|
|
12
|
+
|
|
13
|
+
option :transition
|
|
14
|
+
option :workflow_class, optional: true
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
branch_count = static_branch_count
|
|
18
|
+
limit = Smith.config.parallel_branch_limit
|
|
19
|
+
return unless branch_count && branch_count > limit
|
|
20
|
+
|
|
21
|
+
Diagnostic.new(
|
|
22
|
+
severity: :error,
|
|
23
|
+
code: :parallel_branch_limit_exceeded,
|
|
24
|
+
transition: transition.name,
|
|
25
|
+
message: "Transition #{Reference.format(transition.name)} declares #{branch_count} parallel branches, " \
|
|
26
|
+
"exceeding the configured limit #{limit}.",
|
|
27
|
+
suggestion: "Reduce the branch count or explicitly raise Smith.config.parallel_branch_limit."
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def static_branch_count
|
|
34
|
+
return transition.fanout_config.fetch(:branches).length if transition.fanout?
|
|
35
|
+
|
|
36
|
+
transition.parallel_count if transition.parallel?
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Workflow
|
|
7
|
+
class Graph
|
|
8
|
+
# A workflow with a `budget_ledger` holds each provider request on it,
|
|
9
|
+
# priced from the pricing catalog under the id RubyLLM resolves the
|
|
10
|
+
# model to (as holds and Smith's own usage are): an agent whose model
|
|
11
|
+
# (or fallback model) has no price there gives the ledger holds and
|
|
12
|
+
# amounts without a cost (nil), a warning. The agents are those of each
|
|
13
|
+
# step (a fan-out's branches, an optimizer's generator and evaluator,
|
|
14
|
+
# an orchestrator and its worker) and of a nested workflow without a
|
|
15
|
+
# ledger of its own, which charges this one. A model given by a block
|
|
16
|
+
# is known only per run, and is not checked. A child run (`call`, `map`,
|
|
17
|
+
# `spawn`) is a run of its own, held on its own class's ledger: one
|
|
18
|
+
# whose class names none sends its requests unheld, also a warning.
|
|
19
|
+
class BudgetLedgerDiagnostic
|
|
20
|
+
extend Dry::Initializer
|
|
21
|
+
|
|
22
|
+
param :graph
|
|
23
|
+
|
|
24
|
+
def self.declared?(workflow_class)
|
|
25
|
+
workflow_class.respond_to?(:budget_ledger) && !workflow_class.budget_ledger.nil?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def to_a
|
|
29
|
+
return [] unless self.class.declared?(graph.workflow_class)
|
|
30
|
+
|
|
31
|
+
graph.reachable_transitions.flat_map do |transition|
|
|
32
|
+
[*agents(transition, Set[graph.workflow_class]).uniq.filter_map { unpriced(transition, _1) },
|
|
33
|
+
*unbudgeted_child(transition)]
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def agents(transition, seen)
|
|
40
|
+
[*step_agents(transition), *(nested_agents(transition.workflow_class, seen) if transition.nested?)].compact
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def step_agents(transition)
|
|
44
|
+
return transition.fanout_config.fetch(:branches).values if transition.fanout?
|
|
45
|
+
return transition.optimization_config.values_at(:generator, :evaluator) if transition.optimized?
|
|
46
|
+
return transition.orchestrator_config.values_at(:orchestrator, :worker) if transition.orchestrated?
|
|
47
|
+
|
|
48
|
+
[transition.agent_name]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def unbudgeted_child(transition)
|
|
52
|
+
child = transition.children_config&.fetch(:workflow)
|
|
53
|
+
return if child.nil? || self.class.declared?(child)
|
|
54
|
+
|
|
55
|
+
name = child.name || child.inspect
|
|
56
|
+
Diagnostic.new(
|
|
57
|
+
severity: :warning, code: :child_run_unbudgeted, transition: transition.name, target: name,
|
|
58
|
+
message: "Transition #{Reference.format(transition.name)} starts runs of #{name}, which declares no " \
|
|
59
|
+
"budget_ledger: a child run is a run of its own, so its provider requests are held on no " \
|
|
60
|
+
"ledger and go unapproved and uncounted.",
|
|
61
|
+
suggestion: "Declare budget_ledger on #{name}, scoped by a key the parent passes in each child's " \
|
|
62
|
+
"input (budget_ledger(:account_id) { |scope| ... })."
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# A nested workflow without its own ledger charges this one.
|
|
67
|
+
def nested_agents(workflow_class, seen)
|
|
68
|
+
return [] if seen.include?(workflow_class) || self.class.declared?(workflow_class) ||
|
|
69
|
+
!workflow_class.respond_to?(:graph)
|
|
70
|
+
|
|
71
|
+
seen << workflow_class
|
|
72
|
+
workflow_class.graph.reachable_transitions.flat_map { agents(_1, seen) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def unpriced(transition, agent)
|
|
76
|
+
agent_class = Agent::Registry.binding_for(agent)&.fetch(:agent_class)
|
|
77
|
+
return unless agent_class.respond_to?(:chat_kwargs)
|
|
78
|
+
|
|
79
|
+
model = models(agent_class).find { |reference| !priced?(reference) }
|
|
80
|
+
return unless model
|
|
81
|
+
|
|
82
|
+
Diagnostic.new(
|
|
83
|
+
severity: :warning, code: :budget_ledger_unpriced, transition: transition.name, target: agent,
|
|
84
|
+
message: "Transition #{Reference.format(transition.name)} holds agent #{Reference.format(agent)}'s " \
|
|
85
|
+
"requests on the workflow's budget_ledger, and its model #{model.model_id} has no price " \
|
|
86
|
+
"in Smith.config.pricing, so their amounts carry no cost.",
|
|
87
|
+
suggestion: "Price the model in Smith.config.pricing, or have the ledger count tokens."
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The models the agent's calls are priced under: its own and its
|
|
92
|
+
# fallbacks', as RubyLLM resolves them.
|
|
93
|
+
def models(agent_class)
|
|
94
|
+
kwargs = agent_class.chat_kwargs
|
|
95
|
+
primary = kwargs[:model] && resolved(kwargs[:model], provider_of(agent_class), kwargs[:assume_model_exists])
|
|
96
|
+
[primary, *Array(agent_class.fallback_models).map { reference(Agent::ModelReference.coerce(_1)) }].compact
|
|
97
|
+
rescue StandardError
|
|
98
|
+
[]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def reference(coerced) = resolved(coerced.model_id, coerced.provider&.to_s, false)
|
|
102
|
+
|
|
103
|
+
def resolved(model_id, provider, assume)
|
|
104
|
+
info = RubyLLM::Models.resolve(model_id, provider:, assume_exists: assume || false).first
|
|
105
|
+
Agent::ModelReference.new(model_id: info.id, provider: provider || info.provider.to_s)
|
|
106
|
+
rescue StandardError
|
|
107
|
+
Agent::ModelReference.new(model_id:, provider:)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def provider_of(agent_class)
|
|
111
|
+
provider = agent_class.chat_kwargs[:provider]
|
|
112
|
+
return provider.to_s if provider
|
|
113
|
+
|
|
114
|
+
Smith::Models.find_or_infer(agent_class.chat_kwargs[:model])&.provider&.to_s
|
|
115
|
+
rescue StandardError
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def priced?(reference)
|
|
120
|
+
!Pricing.compute_cost(model: reference.model_id, provider: reference.provider&.to_s, input_tokens: 0,
|
|
121
|
+
output_tokens: 0).nil?
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Workflow
|
|
7
|
+
class Graph
|
|
8
|
+
# A durable retry waits out its backoff in the store, so its longest
|
|
9
|
+
# backoff (jitter included) must end within the persistence TTL, or
|
|
10
|
+
# the stored run would expire while it waits. At runtime such a wait
|
|
11
|
+
# fails the step only after earlier attempts and waits.
|
|
12
|
+
class DurableRetryDiagnostic
|
|
13
|
+
extend Dry::Initializer
|
|
14
|
+
|
|
15
|
+
param :graph
|
|
16
|
+
|
|
17
|
+
def to_a
|
|
18
|
+
ttl = graph.workflow_class.persistence_ttl || Smith.config.persistence_ttl
|
|
19
|
+
return [] unless ttl
|
|
20
|
+
|
|
21
|
+
graph.reachable_transitions.filter_map do |transition|
|
|
22
|
+
config = transition.retry_config
|
|
23
|
+
next unless config&.fetch(:durable, false)
|
|
24
|
+
|
|
25
|
+
longest = longest_backoff(config)
|
|
26
|
+
diagnostic(transition.name, longest, ttl) if longest >= ttl
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def longest_backoff(config)
|
|
33
|
+
attempts = config.fetch(:attempts)
|
|
34
|
+
ExponentialBackoff.new(attempts:, base_delay: config.fetch(:backoff), max_delay: config[:max_delay],
|
|
35
|
+
jitter: config.fetch(:jitter), delay_label: "backoff")
|
|
36
|
+
.delay(attempts - 1, random: -> { ExponentialBackoff::MAX_RANDOM_FACTOR })
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def diagnostic(name, longest, ttl)
|
|
40
|
+
Diagnostic.new(
|
|
41
|
+
severity: :error, code: :durable_retry_outlasts_ttl, transition: name,
|
|
42
|
+
message: "Transition #{Reference.format(name)} waits up to #{longest.round(3)} seconds between attempts, " \
|
|
43
|
+
"which does not end within the persistence TTL of #{ttl} seconds.",
|
|
44
|
+
suggestion: "Lower backoff, max_delay or jitter, or raise the persistence TTL."
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -17,7 +17,7 @@ module Smith
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
def for(transition)
|
|
20
|
-
successful_successors(transition) + failure_successors(transition)
|
|
20
|
+
successful_successors(transition) + failure_successors(transition) + timeout_successors(transition)
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def failure_control_marker(transition)
|
|
@@ -42,15 +42,29 @@ module Smith
|
|
|
42
42
|
successor ? [successor] : []
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
+
# An await that times out continues at its `on_timeout` transition,
|
|
46
|
+
# which starts from the await's own state.
|
|
47
|
+
def timeout_successors(transition)
|
|
48
|
+
target = executable_target(transition.timeout_transition, expected_state: transition.from)
|
|
49
|
+
target ? [target] : []
|
|
50
|
+
end
|
|
51
|
+
|
|
45
52
|
def successful_target_names(transition)
|
|
46
53
|
return Targets.router_for(transition) if transition.routed?
|
|
47
54
|
|
|
48
55
|
names = Array(transition.deterministic_routes).dup
|
|
49
|
-
name = transition.success_transition
|
|
56
|
+
name = transition.success_transition
|
|
57
|
+
name ||= graph.first_transition_from(transition.to)&.name if default_successor?(transition)
|
|
50
58
|
names << name if name
|
|
51
59
|
names.uniq
|
|
52
60
|
end
|
|
53
61
|
|
|
62
|
+
# A step declaring `routes:` reaches only its routes and `on_success`,
|
|
63
|
+
# unless it opts into `fallthrough: true`.
|
|
64
|
+
def default_successor?(transition)
|
|
65
|
+
transition.deterministic_routes.nil? || transition.deterministic_fallthrough
|
|
66
|
+
end
|
|
67
|
+
|
|
54
68
|
def executable_target(name, expected_state:)
|
|
55
69
|
return if name.nil?
|
|
56
70
|
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Workflow
|
|
7
|
+
class Graph
|
|
8
|
+
# A nested workflow's agents, at any depth, are refused a key the
|
|
9
|
+
# nesting workflow marks untrusted. A key a nested workflow marks
|
|
10
|
+
# untrusted itself is in that workflow's own report, so it is left out
|
|
11
|
+
# here. A child run inherits only the keys its `input:` passes under
|
|
12
|
+
# the same names, which readiness cannot see, so past a map, call or
|
|
13
|
+
# spawn the refusal is a warning.
|
|
14
|
+
class InheritedUntrustedDiagnostics
|
|
15
|
+
extend Dry::Initializer
|
|
16
|
+
|
|
17
|
+
param :graph
|
|
18
|
+
# Report entries ({ transition:, workflow_class:, label: }) for the
|
|
19
|
+
# workflows this one nests, and the graphs the readiness walk built.
|
|
20
|
+
option :entries
|
|
21
|
+
option :graphs
|
|
22
|
+
|
|
23
|
+
def to_a
|
|
24
|
+
keys = UntrustedInputDiagnostic.declared_keys(graph.workflow_class)
|
|
25
|
+
return [] if keys.empty?
|
|
26
|
+
|
|
27
|
+
entries.flat_map do |entry|
|
|
28
|
+
runs = entry.fetch(:transition).child_runs?
|
|
29
|
+
reads(entry.fetch(:workflow_class), keys, {}, runs).map { |read| diagnostic(entry, *read) }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# [binding, key, runs] for each agent that reads a key, where runs
|
|
36
|
+
# says a map, call or spawn lies on the way to it.
|
|
37
|
+
def reads(workflow_class, keys, seen, runs)
|
|
38
|
+
nested_graph = graphs[workflow_class]
|
|
39
|
+
return [] if nested_graph.nil? || seen.key?([workflow_class, keys, runs])
|
|
40
|
+
|
|
41
|
+
seen[[workflow_class, keys, runs]] = true
|
|
42
|
+
keys -= UntrustedInputDiagnostic.declared_keys(workflow_class)
|
|
43
|
+
return [] if keys.empty?
|
|
44
|
+
|
|
45
|
+
agent_reads(nested_graph, keys, runs) + inner_reads(nested_graph, keys, seen, runs)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def inner_reads(nested_graph, keys, seen, runs)
|
|
49
|
+
inner = nested_graph.reachable_transitions.select { _1.nested? || _1.child_runs? }
|
|
50
|
+
inner.flat_map { reads(_1.target_workflow_class, keys, seen, runs || _1.child_runs?) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def agent_reads(nested_graph, keys, runs)
|
|
54
|
+
RuntimeBindingDiagnostics.new(nested_graph).agent_bindings.filter_map do |binding|
|
|
55
|
+
key = (UntrustedInputDiagnostic.context_inputs(binding.fetch(:agent)) & keys).first
|
|
56
|
+
[binding, key, runs] if key
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def diagnostic(entry, binding, key, runs)
|
|
61
|
+
name = entry.fetch(:transition).name
|
|
62
|
+
label = entry.fetch(:label)
|
|
63
|
+
Diagnostic.new(
|
|
64
|
+
severity: runs ? :warning : :error, code: :nested_untrusted_agent_input, transition: name,
|
|
65
|
+
target: label, message: "Transition #{ref(name)} #{runs ? "starts runs of" : "nests"} #{label}, where " \
|
|
66
|
+
"#{binding.fetch(:role)} #{ref(binding.fetch(:agent))} on transition " \
|
|
67
|
+
"#{ref(binding.fetch(:transition).name)} declares #{ref(key)}, which this " \
|
|
68
|
+
"workflow marks untrusted, as an input#{" (refused if input: passes it)" if runs}.",
|
|
69
|
+
suggestion: UntrustedInputDiagnostic.suggestion(key)
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def ref(value) = Reference.format(value)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Workflow
|
|
7
|
+
class Graph
|
|
8
|
+
# In an isolated session an agent step has no transcript to read, so it
|
|
9
|
+
# must declare `input:`; without one it fails at runtime. A nested
|
|
10
|
+
# workflow keeps its own session mode, so an isolated workflow that
|
|
11
|
+
# nests a shared one is reported too.
|
|
12
|
+
class IsolatedInputDiagnostic
|
|
13
|
+
extend Dry::Initializer
|
|
14
|
+
|
|
15
|
+
option :transition
|
|
16
|
+
option :workflow_class
|
|
17
|
+
|
|
18
|
+
def call
|
|
19
|
+
return unless isolated?(workflow_class)
|
|
20
|
+
return nested_shared_session if transition.nested?
|
|
21
|
+
return if transition.step_input || !transition.agent_step?
|
|
22
|
+
|
|
23
|
+
name = Reference.format(transition.name)
|
|
24
|
+
Diagnostic.new(
|
|
25
|
+
severity: :error, code: :isolated_step_without_input, transition: transition.name,
|
|
26
|
+
message: "Transition #{name} runs an agent in an isolated session without input:.",
|
|
27
|
+
suggestion: "Declare input: on transition #{name}, or use session :shared."
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def isolated?(klass) = klass.respond_to?(:session) && klass.session == :isolated
|
|
34
|
+
|
|
35
|
+
def nested_shared_session
|
|
36
|
+
child = transition.workflow_class
|
|
37
|
+
return if isolated?(child)
|
|
38
|
+
|
|
39
|
+
label = child.name || child.inspect
|
|
40
|
+
Diagnostic.new(
|
|
41
|
+
severity: :warning, code: :isolated_workflow_nests_shared_session, transition: transition.name,
|
|
42
|
+
target: label,
|
|
43
|
+
message: "Transition #{Reference.format(transition.name)} nests #{label}, whose agents share a transcript.",
|
|
44
|
+
suggestion: "Declare session :isolated on #{label} if its agents should not read one another's output."
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -11,9 +11,12 @@ module Smith
|
|
|
11
11
|
param :graph
|
|
12
12
|
option :reports
|
|
13
13
|
option :cycle_transitions, default: proc { {}.compare_by_identity }
|
|
14
|
+
# The graphs the readiness walk built, by workflow class.
|
|
15
|
+
option :graphs, default: proc { {}.compare_by_identity }
|
|
14
16
|
|
|
15
17
|
def to_a
|
|
16
|
-
cycle_diagnostics +
|
|
18
|
+
cycle_diagnostics + waiting_child_diagnostics + inherited_untrusted_diagnostics +
|
|
19
|
+
report_entries.flat_map { |entry| diagnostics_for(entry) }
|
|
17
20
|
end
|
|
18
21
|
|
|
19
22
|
def child_reports
|
|
@@ -30,7 +33,7 @@ module Smith
|
|
|
30
33
|
|
|
31
34
|
def cycle_diagnostics
|
|
32
35
|
cycle_transitions.keys.map do |transition|
|
|
33
|
-
workflow_class = transition.
|
|
36
|
+
workflow_class = transition.target_workflow_class
|
|
34
37
|
cycle_diagnostic(transition, workflow_class)
|
|
35
38
|
end
|
|
36
39
|
end
|
|
@@ -39,7 +42,7 @@ module Smith
|
|
|
39
42
|
@report_entries ||= nested_workflow_transitions.filter_map do |transition|
|
|
40
43
|
next if cycle_transitions.key?(transition)
|
|
41
44
|
|
|
42
|
-
workflow_class = transition.
|
|
45
|
+
workflow_class = transition.target_workflow_class
|
|
43
46
|
report = reports[workflow_class]
|
|
44
47
|
next unless report
|
|
45
48
|
|
|
@@ -52,7 +55,43 @@ module Smith
|
|
|
52
55
|
end
|
|
53
56
|
|
|
54
57
|
def nested_workflow_transitions
|
|
55
|
-
@nested_workflow_transitions ||= graph.reachable_transitions.select
|
|
58
|
+
@nested_workflow_transitions ||= graph.reachable_transitions.select do |transition|
|
|
59
|
+
transition.nested? || transition.child_runs?
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# An in-process child cannot wait: a nested workflow, or a child run
|
|
64
|
+
# the launcher runs in process (`inline?`), that reaches an await, a
|
|
65
|
+
# delay, a durable retry or a batch step fails its parent's step. A nested
|
|
66
|
+
# workflow cannot launch runs either, unless the launcher runs them
|
|
67
|
+
# in process; a child run of a queueing launcher is a run of its own
|
|
68
|
+
# and may wait.
|
|
69
|
+
def waiting_child_diagnostics
|
|
70
|
+
launcher = Smith.config.run_launcher
|
|
71
|
+
inline = launcher.respond_to?(:inline?) && launcher.inline? == true
|
|
72
|
+
report_entries.filter_map do |entry|
|
|
73
|
+
transition = entry.fetch(:transition)
|
|
74
|
+
next unless (transition.nested? || (inline && transition.child_runs?)) && waiting_child?(entry, inline)
|
|
75
|
+
|
|
76
|
+
WaitingChildDiagnostic.build(transition, workflow_label(entry.fetch(:workflow_class)))
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def waiting_child?(entry, inline)
|
|
81
|
+
kinds = inline ? %i[await delay] : %i[await delay map call spawn]
|
|
82
|
+
entry.fetch(:report).topology_report.transitions.any? { waits?(_1, kinds) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def waits?(snapshot, kinds)
|
|
86
|
+
kinds.include?(snapshot.kind) || snapshot.retry_policy&.dig(:durable) == true ||
|
|
87
|
+
%i[batch callable].include?(snapshot.mode)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def inherited_untrusted_diagnostics
|
|
91
|
+
entries = report_entries.map do |entry|
|
|
92
|
+
entry.merge(label: workflow_label(entry.fetch(:workflow_class)))
|
|
93
|
+
end
|
|
94
|
+
InheritedUntrustedDiagnostics.new(graph, entries:, graphs:).to_a
|
|
56
95
|
end
|
|
57
96
|
|
|
58
97
|
def cycle_diagnostic(transition, workflow_class)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Workflow
|
|
7
|
+
class Graph
|
|
8
|
+
# A step's `output_to:` key, or an await's `into:` key, that an explicit
|
|
9
|
+
# `persist` list leaves out fails the step at runtime.
|
|
10
|
+
class OutputKeyDiagnostic
|
|
11
|
+
extend Dry::Initializer
|
|
12
|
+
|
|
13
|
+
option :transition
|
|
14
|
+
option :workflow_class
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
option, key = output_key
|
|
18
|
+
return unless key
|
|
19
|
+
|
|
20
|
+
keys = persisted_keys
|
|
21
|
+
return if keys.nil? || keys.include?(key)
|
|
22
|
+
|
|
23
|
+
Diagnostic.new(
|
|
24
|
+
severity: :error, code: :"#{option}_not_persisted", transition: transition.name, target: key,
|
|
25
|
+
message: "Transition #{Reference.format(transition.name)} writes #{Reference.format(key)} " \
|
|
26
|
+
"(#{option}:), which the context's persist list leaves out.",
|
|
27
|
+
suggestion: "Add #{Reference.format(key)} to the context's persist list."
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def output_key
|
|
34
|
+
return [:output_to, transition.output_to] if transition.output_to
|
|
35
|
+
|
|
36
|
+
into = transition.await_config&.fetch(:into, nil)
|
|
37
|
+
[:await_into, into] if into
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The explicit list, or nil when the workflow has none or uses
|
|
41
|
+
# `persist :auto`.
|
|
42
|
+
def persisted_keys
|
|
43
|
+
manager = workflow_class.context_manager if workflow_class.respond_to?(:context_manager)
|
|
44
|
+
return unless manager.respond_to?(:persist)
|
|
45
|
+
return if manager.respond_to?(:persist_mode) && manager.persist_mode == :auto
|
|
46
|
+
|
|
47
|
+
keys = manager.persist
|
|
48
|
+
keys.empty? ? nil : keys
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Workflow
|
|
7
|
+
class Graph
|
|
8
|
+
# An agent declared for Anthropic whose output schema Anthropic's
|
|
9
|
+
# structured outputs cannot take (an object that allows other
|
|
10
|
+
# properties or declares none) fails before its call.
|
|
11
|
+
class OutputSchemaDiagnostic
|
|
12
|
+
extend Dry::Initializer
|
|
13
|
+
|
|
14
|
+
option :binding
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
agent_class = Agent::Registry.binding_for(binding.fetch(:agent))&.fetch(:agent_class)
|
|
18
|
+
payload = anthropic_schema(agent_class)
|
|
19
|
+
return unless payload
|
|
20
|
+
|
|
21
|
+
Providers::SchemaConstraints.for_provider(payload, "anthropic")
|
|
22
|
+
nil
|
|
23
|
+
rescue Smith::WorkflowError => e
|
|
24
|
+
diagnostic(e)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def anthropic_schema(agent_class)
|
|
30
|
+
return unless agent_class.respond_to?(:chat_kwargs) && agent_class.chat_kwargs[:provider].to_s == "anthropic"
|
|
31
|
+
|
|
32
|
+
schema = agent_class.output_schema || (agent_class.schema if agent_class.respond_to?(:schema))
|
|
33
|
+
Providers::SchemaConstraints.json_schema(schema) if schema && !schema.is_a?(Proc)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def diagnostic(error)
|
|
37
|
+
agent = binding.fetch(:agent)
|
|
38
|
+
Diagnostic.new(
|
|
39
|
+
severity: :error, code: :output_schema_unsupported, transition: binding.fetch(:transition).name,
|
|
40
|
+
target: agent,
|
|
41
|
+
message: "Agent #{Reference.format(agent)} declares an output schema Anthropic cannot take: " \
|
|
42
|
+
"#{error.message}.",
|
|
43
|
+
suggestion: "Give every object in the schema its properties and additionalProperties: false."
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|