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
|
@@ -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
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Workflow
|
|
7
|
+
class Graph
|
|
8
|
+
# A map, call or spawn step needs a run launcher to start its runs.
|
|
9
|
+
class RunLauncherDiagnostic
|
|
10
|
+
extend Dry::Initializer
|
|
11
|
+
|
|
12
|
+
param :graph
|
|
13
|
+
|
|
14
|
+
def to_a
|
|
15
|
+
return [] if Smith.config.run_launcher
|
|
16
|
+
|
|
17
|
+
graph.reachable_transitions.select(&:child_runs?).map do |transition|
|
|
18
|
+
kind = transition.children_config.fetch(:kind)
|
|
19
|
+
Diagnostic.new(
|
|
20
|
+
severity: :error, code: :run_launcher_missing, transition: transition.name,
|
|
21
|
+
message: "Transition #{Reference.format(transition.name)} is a #{kind}, which starts runs through " \
|
|
22
|
+
"Smith.config.run_launcher, and none is configured.",
|
|
23
|
+
suggestion: "Set Smith.config.run_launcher (Smith::Launchers::Inline runs children in process)."
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -22,7 +22,7 @@ module Smith
|
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
agent_class = registry_binding.fetch(:agent_class)
|
|
25
|
-
return if agent_class.model_configured?
|
|
25
|
+
return if agent_class.model_configured? || fallback_models?(agent_class)
|
|
26
26
|
return required_model_diagnostic if model_required?
|
|
27
27
|
|
|
28
28
|
model_warning
|
|
@@ -80,18 +80,24 @@ module Smith
|
|
|
80
80
|
)
|
|
81
81
|
end
|
|
82
82
|
|
|
83
|
+
# An agent with neither a model nor fallback models fails when its
|
|
84
|
+
# step runs.
|
|
83
85
|
def model_warning
|
|
84
86
|
Diagnostic.new(
|
|
85
|
-
severity: :
|
|
87
|
+
severity: :error,
|
|
86
88
|
code: :agent_without_model,
|
|
87
89
|
transition: transition.name,
|
|
88
90
|
target: agent,
|
|
89
91
|
message: "Transition #{ref(transition.name)} references #{role} #{ref(agent)}, " \
|
|
90
92
|
"but the registered agent has no model configured.",
|
|
91
|
-
suggestion: "Configure a model for agent #{ref(agent)}
|
|
93
|
+
suggestion: "Configure a model (or fallback models) for agent #{ref(agent)}."
|
|
92
94
|
)
|
|
93
95
|
end
|
|
94
96
|
|
|
97
|
+
def fallback_models?(agent_class)
|
|
98
|
+
!agent_class.fallback_models_block.nil? || Array(agent_class.fallback_models).any?
|
|
99
|
+
end
|
|
100
|
+
|
|
95
101
|
def agent
|
|
96
102
|
binding.fetch(:agent)
|
|
97
103
|
end
|
|
@@ -11,9 +11,13 @@ module Smith
|
|
|
11
11
|
param :graph
|
|
12
12
|
|
|
13
13
|
def to_a
|
|
14
|
-
agent_bindings.
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
agent_bindings.flat_map do |binding|
|
|
15
|
+
[
|
|
16
|
+
RuntimeBindingDiagnosticBuilder.new(graph, binding: binding).to_diagnostic,
|
|
17
|
+
UntrustedInputDiagnostic.new(graph, binding:).call,
|
|
18
|
+
OutputSchemaDiagnostic.new(binding:).call
|
|
19
|
+
]
|
|
20
|
+
end.compact
|
|
17
21
|
end
|
|
18
22
|
|
|
19
23
|
def agent_bindings
|
|
@@ -31,10 +31,17 @@ module Smith
|
|
|
31
31
|
invalid_agent_bindings_count: diagnostic_count(:invalid_agent_binding),
|
|
32
32
|
uninspectable_agent_bindings_count: diagnostic_count(:uninspectable_agent_binding),
|
|
33
33
|
modelless_agent_bindings_count: diagnostic_count(:agent_without_model),
|
|
34
|
-
required_model_missing_count: diagnostic_count(:agent_without_required_model)
|
|
34
|
+
required_model_missing_count: diagnostic_count(:agent_without_required_model),
|
|
35
|
+
budget_ledger: budget_ledger_metric
|
|
35
36
|
}
|
|
36
37
|
end
|
|
37
38
|
|
|
39
|
+
# The scope keys of the workflow's budget_ledger, or nil for none.
|
|
40
|
+
def budget_ledger_metric
|
|
41
|
+
klass = graph.workflow_class
|
|
42
|
+
{ scope_keys: klass.budget_ledger_scope_keys } if BudgetLedgerDiagnostic.declared?(klass)
|
|
43
|
+
end
|
|
44
|
+
|
|
38
45
|
def binding_metrics
|
|
39
46
|
{
|
|
40
47
|
direct_agent_bindings_count: agent_bindings.length,
|
|
@@ -11,12 +11,13 @@ module Smith
|
|
|
11
11
|
param :graph
|
|
12
12
|
option :reports
|
|
13
13
|
option :cycle_transitions
|
|
14
|
+
option :graphs, default: proc { {}.compare_by_identity }
|
|
14
15
|
|
|
15
16
|
def call
|
|
16
17
|
topology_report = graph.validate
|
|
17
18
|
bindings = RuntimeBindingDiagnostics.new(graph)
|
|
18
|
-
nested = NestedReadinessDiagnostics.new(graph, reports:, cycle_transitions:)
|
|
19
|
-
diagnostics =
|
|
19
|
+
nested = NestedReadinessDiagnostics.new(graph, reports:, cycle_transitions:, graphs:)
|
|
20
|
+
diagnostics = runtime_diagnostics(bindings, nested)
|
|
20
21
|
RuntimeReadinessReport.new(
|
|
21
22
|
workflow_class: workflow_label,
|
|
22
23
|
topology_report: topology_report,
|
|
@@ -27,6 +28,12 @@ module Smith
|
|
|
27
28
|
|
|
28
29
|
private
|
|
29
30
|
|
|
31
|
+
def runtime_diagnostics(bindings, nested)
|
|
32
|
+
[*bindings.to_a, *RunLauncherDiagnostic.new(graph).to_a, *DurableRetryDiagnostic.new(graph).to_a,
|
|
33
|
+
*StreamDiagnostic.new(graph).to_a, *BatchDiagnostic.new(graph).to_a,
|
|
34
|
+
*BudgetLedgerDiagnostic.new(graph).to_a, *nested.to_a]
|
|
35
|
+
end
|
|
36
|
+
|
|
30
37
|
def metrics(topology_report, diagnostics, bindings, nested)
|
|
31
38
|
RuntimeReadinessMetrics.new(
|
|
32
39
|
graph,
|
|
@@ -62,9 +62,9 @@ module Smith
|
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
def visit_child(parent_graph, transition, frames)
|
|
65
|
-
workflow_class = transition.
|
|
65
|
+
workflow_class = transition.target_workflow_class
|
|
66
66
|
case @states[workflow_class]
|
|
67
|
-
when :visiting then mark_cycle(parent_graph, transition)
|
|
67
|
+
when :visiting then mark_cycle(parent_graph, transition) unless transition.child_runs?
|
|
68
68
|
when :done then nil
|
|
69
69
|
else
|
|
70
70
|
child_graph = graph_for(workflow_class)
|
|
@@ -78,7 +78,8 @@ module Smith
|
|
|
78
78
|
@reports[workflow_class] = RuntimeReadinessReportBuilder.new(
|
|
79
79
|
current_graph,
|
|
80
80
|
reports: @reports,
|
|
81
|
-
cycle_transitions: cycles_for(current_graph)
|
|
81
|
+
cycle_transitions: cycles_for(current_graph),
|
|
82
|
+
graphs: @graphs
|
|
82
83
|
).call
|
|
83
84
|
@states[workflow_class] = :done
|
|
84
85
|
end
|
|
@@ -88,7 +89,9 @@ module Smith
|
|
|
88
89
|
end
|
|
89
90
|
|
|
90
91
|
def nested_transitions_for(current_graph)
|
|
91
|
-
@nested_transitions[current_graph] ||= current_graph.reachable_transitions.select
|
|
92
|
+
@nested_transitions[current_graph] ||= current_graph.reachable_transitions.select do |transition|
|
|
93
|
+
transition.nested? || transition.child_runs?
|
|
94
|
+
end.freeze
|
|
92
95
|
end
|
|
93
96
|
|
|
94
97
|
def mark_cycle(current_graph, transition)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Workflow
|
|
7
|
+
class Graph
|
|
8
|
+
# A streamed step needs its sink in Smith.config.stream_sinks (the
|
|
9
|
+
# step fails without it). An agent whose calls go to OpenAI's Responses
|
|
10
|
+
# API (asked for in its params, or chosen by Smith's tool routing under
|
|
11
|
+
# `openai_api_mode` :auto, as at runtime) is not streamed: its sink
|
|
12
|
+
# gets each reply as one delta.
|
|
13
|
+
class StreamDiagnostic
|
|
14
|
+
extend Dry::Initializer
|
|
15
|
+
|
|
16
|
+
param :graph
|
|
17
|
+
|
|
18
|
+
def to_a
|
|
19
|
+
graph.reachable_transitions.filter_map do |transition|
|
|
20
|
+
next unless transition.stream
|
|
21
|
+
next missing_sink(transition) unless Smith.config.stream_sinks.key?(transition.stream)
|
|
22
|
+
|
|
23
|
+
whole_replies(transition) if responses_route?(transition.agent_name)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def responses_route?(agent)
|
|
30
|
+
agent_class = Agent::Registry.binding_for(agent)&.fetch(:agent_class)
|
|
31
|
+
return false unless agent_class.respond_to?(:chat_kwargs)
|
|
32
|
+
|
|
33
|
+
profiles = static_models(agent_class).filter_map { profile(_1) }.select { _1.provider == :openai }
|
|
34
|
+
return false if profiles.empty?
|
|
35
|
+
return true if params(agent_class)[:openai_api_mode].to_s == "responses"
|
|
36
|
+
|
|
37
|
+
profiles.any? { |profile| routed?(profile, agent_class) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Tools given by a block are known only per run: they may route.
|
|
41
|
+
def routed?(profile, agent_class)
|
|
42
|
+
tools = agent_class.respond_to?(:tools) ? agent_class.tools : []
|
|
43
|
+
return Smith.config.openai_api_mode == :auto unless tools.is_a?(Array)
|
|
44
|
+
|
|
45
|
+
Smith::Models::ToolRouting.responses?(profile:, tools:, thinking: thinking?(agent_class))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def static_models(agent_class)
|
|
49
|
+
primary = agent_class.chat_kwargs[:model] &&
|
|
50
|
+
Agent::ModelReference.new(model_id: agent_class.chat_kwargs[:model],
|
|
51
|
+
provider: agent_class.chat_kwargs[:provider])
|
|
52
|
+
fallbacks = agent_class.fallback_models_block ? [] : Array(agent_class.fallback_models)
|
|
53
|
+
[primary, *fallbacks.map { Agent::ModelReference.coerce(_1) }].compact
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def profile(reference)
|
|
57
|
+
Smith::Models.find_or_infer(reference.model_id, provider: reference.provider&.to_sym)
|
|
58
|
+
rescue StandardError
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def params(agent_class)
|
|
63
|
+
params = agent_class.params if agent_class.respond_to?(:params)
|
|
64
|
+
params.is_a?(Hash) ? params.transform_keys(&:to_sym) : {}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def thinking?(agent_class)
|
|
68
|
+
configured = agent_class.thinking if agent_class.respond_to?(:thinking)
|
|
69
|
+
!configured.nil? || params(agent_class).keys.intersect?(%i[thinking reasoning reasoning_effort])
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def missing_sink(transition)
|
|
73
|
+
Diagnostic.new(
|
|
74
|
+
severity: :error, code: :stream_sink_missing, transition: transition.name, target: transition.stream,
|
|
75
|
+
message: "Transition #{Reference.format(transition.name)} streams to #{transition.stream.inspect}, " \
|
|
76
|
+
"which Smith.config.stream_sinks does not name.",
|
|
77
|
+
suggestion: "Register the sink: Smith.config.stream_sinks = { #{transition.stream.inspect} => sink }."
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def whole_replies(transition)
|
|
82
|
+
Diagnostic.new(
|
|
83
|
+
severity: :warning, code: :stream_whole_replies, transition: transition.name,
|
|
84
|
+
target: transition.agent_name,
|
|
85
|
+
message: "Transition #{Reference.format(transition.name)} streams agent " \
|
|
86
|
+
"#{Reference.format(transition.agent_name)}, whose calls go to OpenAI's Responses API, which " \
|
|
87
|
+
"does not stream: its sink gets each reply as one delta.",
|
|
88
|
+
suggestion: "Accept whole replies there, or keep the agent on Chat Completions " \
|
|
89
|
+
"(Smith.config.openai_api_mode = :off, and no openai_api_mode param)."
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -23,8 +23,17 @@ module Smith
|
|
|
23
23
|
option :orchestrator_config, default: proc {}
|
|
24
24
|
option :fanout_config, default: proc {}
|
|
25
25
|
option :retry_config, default: proc {}
|
|
26
|
+
option :await_config, default: proc {}
|
|
27
|
+
option :timeout_transition, default: proc {}
|
|
28
|
+
option :step_input, default: proc { false }
|
|
29
|
+
option :output_to, default: proc {}
|
|
30
|
+
option :stream, default: proc {}
|
|
31
|
+
option :batch_mode, default: proc {}
|
|
32
|
+
option :on_request_change, default: proc {}
|
|
33
|
+
option :children_config, default: proc {}
|
|
26
34
|
option :deterministic_kind, default: proc {}
|
|
27
35
|
option :deterministic_routes, default: proc {}
|
|
36
|
+
option :deterministic_fallthrough, default: proc { false }
|
|
28
37
|
option :deterministic, default: proc { false }
|
|
29
38
|
option :parallel, default: proc { false }
|
|
30
39
|
option :parallel_count, default: proc {}
|
|
@@ -41,6 +50,16 @@ module Smith
|
|
|
41
50
|
def nested? = !workflow_class.nil?
|
|
42
51
|
def routed? = !router_config.nil?
|
|
43
52
|
def parallel? = parallel
|
|
53
|
+
def awaits? = !await_config.nil?
|
|
54
|
+
def delay? = await_config&.fetch(:kind) == :delay
|
|
55
|
+
def agent_step? = !agent_name.nil? || optimized? || orchestrated? || fanout?
|
|
56
|
+
def child_runs? = !children_config.nil?
|
|
57
|
+
def spawns? = children_config&.fetch(:kind) == :spawn
|
|
58
|
+
def batches? = !batch_mode.nil? && batch_mode != :sync
|
|
59
|
+
|
|
60
|
+
# The workflow class this step runs: nested in process, or launched
|
|
61
|
+
# as runs of their own.
|
|
62
|
+
def target_workflow_class = workflow_class || children_config&.fetch(:workflow)
|
|
44
63
|
end
|
|
45
64
|
end
|
|
46
65
|
end
|
|
@@ -36,7 +36,9 @@ module Smith
|
|
|
36
36
|
{
|
|
37
37
|
success_transition: project(transition.success_transition),
|
|
38
38
|
failure_transition: project(transition.failure_transition),
|
|
39
|
-
|
|
39
|
+
timeout_transition: project(transition.timeout_transition),
|
|
40
|
+
deterministic_routes: project_array(transition.deterministic_routes),
|
|
41
|
+
deterministic_fallthrough: transition.deterministic_fallthrough == true
|
|
40
42
|
}
|
|
41
43
|
end
|
|
42
44
|
|
|
@@ -45,10 +47,31 @@ module Smith
|
|
|
45
47
|
deterministic_kind: transition.deterministic_kind,
|
|
46
48
|
deterministic: transition.deterministic?,
|
|
47
49
|
parallel: transition.parallel?,
|
|
48
|
-
parallel_count: static_parallel_count
|
|
50
|
+
parallel_count: static_parallel_count,
|
|
51
|
+
step_input: !transition.step_input.nil?,
|
|
52
|
+
output_to: transition.output_to,
|
|
53
|
+
**call_attributes,
|
|
54
|
+
children_config: children_config
|
|
49
55
|
}
|
|
50
56
|
end
|
|
51
57
|
|
|
58
|
+
# How the step's calls are made: streamed to a sink, and :sync,
|
|
59
|
+
# :batch or :callable (a mode chosen per attempt), and what a batch
|
|
60
|
+
# result does when the step renders another request by then.
|
|
61
|
+
def call_attributes
|
|
62
|
+
mode = transition.batch_mode
|
|
63
|
+
{ stream: transition.stream, batch_mode: mode.respond_to?(:call) ? :callable : mode,
|
|
64
|
+
on_request_change: transition.on_request_change }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def children_config
|
|
68
|
+
config = transition.children_config
|
|
69
|
+
return unless config
|
|
70
|
+
|
|
71
|
+
items = config[:items].respond_to?(:call) ? :callable : config[:items]
|
|
72
|
+
config.slice(:kind, :workflow, :max_items, :collect, :on_item_failure).merge(items:).freeze
|
|
73
|
+
end
|
|
74
|
+
|
|
52
75
|
def static_parallel_count
|
|
53
76
|
return unless transition.parallel?
|
|
54
77
|
|
|
@@ -19,7 +19,8 @@ module Smith
|
|
|
19
19
|
optimization_config: optimization_config,
|
|
20
20
|
orchestrator_config: orchestrator_config,
|
|
21
21
|
fanout_config: fanout_config,
|
|
22
|
-
retry_config: retry_config
|
|
22
|
+
retry_config: retry_config,
|
|
23
|
+
await_config: await_config
|
|
23
24
|
}
|
|
24
25
|
end
|
|
25
26
|
|
|
@@ -61,17 +62,37 @@ module Smith
|
|
|
61
62
|
{ branches: own_hash(branches) }.freeze if branches
|
|
62
63
|
end
|
|
63
64
|
|
|
65
|
+
# The wait's kind, event, context key and timeout (`:callable` when
|
|
66
|
+
# the timeout is computed from the context); the block is not shown.
|
|
67
|
+
def await_config
|
|
68
|
+
config = transition.await_config
|
|
69
|
+
return unless config
|
|
70
|
+
|
|
71
|
+
timeout = config.fetch(:timeout)
|
|
72
|
+
{
|
|
73
|
+
kind: config.fetch(:kind), event: own(config[:event]), into: config[:into],
|
|
74
|
+
timeout: timeout.respond_to?(:call) ? :callable : timeout
|
|
75
|
+
}.compact.freeze
|
|
76
|
+
end
|
|
77
|
+
|
|
64
78
|
def retry_config
|
|
65
79
|
config = transition.retry_config
|
|
66
80
|
return unless config
|
|
67
81
|
|
|
68
|
-
{
|
|
82
|
+
exported = {
|
|
69
83
|
error_classes: config.fetch(:error_classes).dup.freeze,
|
|
70
84
|
attempts: config.fetch(:attempts),
|
|
71
85
|
backoff: config.fetch(:backoff),
|
|
72
86
|
max_delay: config[:max_delay],
|
|
73
87
|
jitter: config.fetch(:jitter)
|
|
74
|
-
}
|
|
88
|
+
}
|
|
89
|
+
exported.merge(optional_retry_fields(config)).freeze
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def optional_retry_fields(config)
|
|
93
|
+
new_key_classes = config.fetch(:new_idempotency_key_on, [])
|
|
94
|
+
{ new_idempotency_key_on: (new_key_classes.dup.freeze if new_key_classes.any?),
|
|
95
|
+
durable: (true if config[:durable]) }.compact
|
|
75
96
|
end
|
|
76
97
|
|
|
77
98
|
def own_hash(hash)
|