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
|
@@ -15,23 +15,43 @@ module Smith
|
|
|
15
15
|
def bridge_workflow_inputs(agent_class)
|
|
16
16
|
return {} unless @context.is_a?(Hash)
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
user_declared
|
|
18
|
+
user_declared = (agent_class.inputs || []) - Smith::Agent::RESERVED_INPUT_NAMES
|
|
19
|
+
reject_untrusted_inputs!(agent_class, user_declared)
|
|
20
20
|
user_declared.to_h do |name|
|
|
21
21
|
[name, @context[name]]
|
|
22
22
|
end
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
# A key the run's context, or that of a workflow it is nested in,
|
|
26
|
+
# marks untrusted never reaches an agent as a declared input, where it
|
|
27
|
+
# would sit beside the agent's instructions. Reserved input names are
|
|
28
|
+
# never read from the context, so they are not checked.
|
|
29
|
+
def reject_untrusted_inputs!(agent_class, declared)
|
|
30
|
+
untrusted = declared & untrusted_context_keys
|
|
31
|
+
return if untrusted.empty?
|
|
32
|
+
|
|
33
|
+
raise WorkflowError, "agent #{agent_class.register_as.inspect} declares untrusted context key " \
|
|
34
|
+
"#{untrusted.first.inspect} as an input"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The messages, with their content parts (ContentParts) as `provider`
|
|
38
|
+
# takes them.
|
|
39
|
+
def add_prepared_input(chat, prepared_input, provider = nil)
|
|
26
40
|
return unless prepared_input
|
|
27
41
|
|
|
28
42
|
prepared_input = provider_safe_prepared_input(prepared_input)
|
|
29
|
-
|
|
30
|
-
|
|
43
|
+
check_cache_breakpoints!(chat, prepared_input, provider)
|
|
44
|
+
with_built_contents do
|
|
45
|
+
prepared_input = prepared_input.map do |message|
|
|
46
|
+
Message.parts?(message) ? provider_parts_message(message, provider) : message
|
|
47
|
+
end
|
|
48
|
+
system_messages, other_messages = prepared_input.partition do |message|
|
|
49
|
+
message_role(message) == :system
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
merge_system_messages!(chat, system_messages) if system_messages.any?
|
|
53
|
+
other_messages.each { |message| add_message(chat, message) }
|
|
31
54
|
end
|
|
32
|
-
|
|
33
|
-
merge_system_messages!(chat, system_messages) if system_messages.any?
|
|
34
|
-
other_messages.each { |message| add_message(chat, message) }
|
|
35
55
|
end
|
|
36
56
|
|
|
37
57
|
def provider_safe_prepared_input(prepared_input)
|
|
@@ -41,13 +61,16 @@ module Smith
|
|
|
41
61
|
messages + [{ role: :user, content: WORKFLOW_CONTINUATION_MESSAGE }]
|
|
42
62
|
end
|
|
43
63
|
|
|
64
|
+
# The input ends with the output the run last added to its transcript,
|
|
65
|
+
# so the provider gets a user turn after it rather than an assistant
|
|
66
|
+
# turn to continue.
|
|
44
67
|
def workflow_handoff?(messages)
|
|
45
68
|
message = messages.reverse_each.find { |candidate| message_role(candidate) != :system }
|
|
46
69
|
return false unless message
|
|
47
70
|
return false unless message_role(message) == :assistant
|
|
48
|
-
return false unless defined?(@
|
|
71
|
+
return false unless defined?(@last_transcript_output) && !@last_transcript_output.nil?
|
|
49
72
|
|
|
50
|
-
message_content(message) == @
|
|
73
|
+
message_content(message) == @last_transcript_output
|
|
51
74
|
end
|
|
52
75
|
|
|
53
76
|
def merge_system_messages!(chat, prepared_system_messages)
|
|
@@ -93,12 +116,12 @@ module Smith
|
|
|
93
116
|
# { text:, ...attachments } and opens each value as a file, so replaying a prior structured
|
|
94
117
|
# output to the next agent in a workflow session raises Errno::ENOENT. Serialize non-string
|
|
95
118
|
# content to JSON so the provider sees the prior output as text; the session store keeps the raw
|
|
96
|
-
# value (last_output stays structured), only this provider-facing copy is serialized.
|
|
97
|
-
#
|
|
98
|
-
#
|
|
119
|
+
# value (last_output stays structured), only this provider-facing copy is serialized. The only
|
|
120
|
+
# content that passes as it is: what Smith built from content parts for this very chat
|
|
121
|
+
# (ContentParts), never a RubyLLM object a session, output or host put there.
|
|
99
122
|
def provider_safe_message(attributes)
|
|
100
123
|
content = attributes[:content]
|
|
101
|
-
return attributes if content.nil? || content.is_a?(String)
|
|
124
|
+
return attributes if content.nil? || content.is_a?(String) || built_content?(content)
|
|
102
125
|
|
|
103
126
|
attributes.merge(content: json_message_content(content))
|
|
104
127
|
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "output_integrity"
|
|
3
4
|
require_relative "provider_completion"
|
|
4
5
|
require_relative "usage_tracking"
|
|
5
6
|
|
|
@@ -8,6 +9,7 @@ module Smith
|
|
|
8
9
|
module Lifecycle
|
|
9
10
|
include ProviderCompletion
|
|
10
11
|
include UsageTracking
|
|
12
|
+
include OutputIntegrity
|
|
11
13
|
|
|
12
14
|
private
|
|
13
15
|
|
|
@@ -18,12 +20,21 @@ module Smith
|
|
|
18
20
|
instance.after_completion(result, context)
|
|
19
21
|
end
|
|
20
22
|
|
|
21
|
-
|
|
23
|
+
# One invocation: the provider call, then whatever OutputIntegrity
|
|
24
|
+
# asks for until the response can be the output.
|
|
25
|
+
def invoke_agent(agent_class, prepared_input, output_schema: effective_output_schema(agent_class))
|
|
22
26
|
check_deadline!
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
+
plan = output_plan(agent_class, prepared_input, output_schema)
|
|
28
|
+
attempt = complete_with_provider(agent_class, prepared_input, shape: plan.shape)
|
|
29
|
+
loop do
|
|
30
|
+
result = account_attempt(agent_class, attempt.completion, attempt.model_reference,
|
|
31
|
+
attempt_id: attempt.attempt_id)
|
|
32
|
+
follow_up = output_follow_up(plan, attempt, result)
|
|
33
|
+
return finalize_output(agent_class, result) unless follow_up
|
|
34
|
+
|
|
35
|
+
settle_rejected(result)
|
|
36
|
+
attempt = follow_up.call
|
|
37
|
+
end
|
|
27
38
|
end
|
|
28
39
|
end
|
|
29
40
|
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Agent
|
|
5
|
+
# The model a chat actually holds (after RubyLLM resolved it), else the
|
|
6
|
+
# reference the attempt was made with.
|
|
7
|
+
module ObservedModel
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def observed_model_reference(chat, fallback:)
|
|
11
|
+
model = observable_model(chat)
|
|
12
|
+
return fallback unless model.respond_to?(:id) && !model.id.to_s.empty?
|
|
13
|
+
|
|
14
|
+
ModelReference.coerce(model.id, provider: observed_provider(model, fallback))
|
|
15
|
+
rescue StandardError
|
|
16
|
+
fallback
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def observable_model(chat)
|
|
20
|
+
resolved_chat = chat.respond_to?(:to_llm) ? chat.to_llm : chat
|
|
21
|
+
resolved_chat.model if resolved_chat.respond_to?(:model)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def observed_provider(model, fallback)
|
|
25
|
+
model.respond_to?(:provider) && !model.provider.to_s.empty? ? model.provider : fallback.provider
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Agent
|
|
5
|
+
# The problems an agent's `output_contract` finds in its output, as
|
|
6
|
+
# "path: problem" lines. A contract's `call(hash)` returns true or false,
|
|
7
|
+
# or a result answering `success?` and `errors`: a Hash of messages by
|
|
8
|
+
# key (nested as deep as the output), an Array of messages, or anything
|
|
9
|
+
# with `to_h` giving such a Hash (as dry-validation's errors do). A
|
|
10
|
+
# failure with no messages still counts.
|
|
11
|
+
module OutputContractCheck
|
|
12
|
+
FAILED = "/: fails the output contract"
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def problems(contract, output)
|
|
17
|
+
return [] unless contract
|
|
18
|
+
|
|
19
|
+
verdict = contract.call(output)
|
|
20
|
+
return verdict ? [] : [FAILED] if [true, false].include?(verdict)
|
|
21
|
+
return [] if readable!(verdict).success?
|
|
22
|
+
|
|
23
|
+
lines = messages(verdict.respond_to?(:errors) ? verdict.errors : nil)
|
|
24
|
+
lines.empty? ? [FAILED] : lines.first(OutputFailure::MAX_PROBLEMS)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def readable!(verdict)
|
|
28
|
+
return verdict if verdict.respond_to?(:success?)
|
|
29
|
+
|
|
30
|
+
raise WorkflowError, "output_contract must return true, false, or a result answering success? and errors"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def messages(errors)
|
|
34
|
+
case errors
|
|
35
|
+
when Hash then lines(errors)
|
|
36
|
+
when Array then errors.map { |message| "/: #{message}" }
|
|
37
|
+
when nil then []
|
|
38
|
+
else errors.respond_to?(:to_h) && !errors.is_a?(String) ? lines(errors.to_h) : ["/: #{errors}"]
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# { fields: { title: ["is missing"] } } becomes "/fields/title: is missing".
|
|
43
|
+
def lines(errors, path = "")
|
|
44
|
+
errors.flat_map do |key, value|
|
|
45
|
+
at = key.nil? ? path : "#{path}/#{key}"
|
|
46
|
+
next lines(value, at) if value.is_a?(Hash)
|
|
47
|
+
|
|
48
|
+
Array(value).map { |message| "#{at.empty? ? "/" : at}: #{message}" }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "output_contract_check"
|
|
6
|
+
require_relative "output_recovery"
|
|
7
|
+
|
|
8
|
+
module Smith
|
|
9
|
+
class Agent
|
|
10
|
+
# Accepting an agent's response as its output. A refused response takes
|
|
11
|
+
# the agent's refusal fallback once or raises OutputRefused; a cut-off
|
|
12
|
+
# one is retried once with a larger output limit or raises
|
|
13
|
+
# OutputTruncated (unless the agent accepts it). The response then goes
|
|
14
|
+
# through the agent's `after_completion`; a structured output that is
|
|
15
|
+
# not a JSON object, breaks a schema constraint or fails the agent's
|
|
16
|
+
# output contract is sent back for repair up to `output_repairs`, then
|
|
17
|
+
# raises OutputInvalid.
|
|
18
|
+
#
|
|
19
|
+
# No follow-up call is made once a tool has run in the step (the tools
|
|
20
|
+
# would run again), once the call has spent what its step reserved, or
|
|
21
|
+
# past MAX_CALLS; a follow-up that fails raises the output failure it
|
|
22
|
+
# was making up for, with the provider's error as its cause. Every
|
|
23
|
+
# response is billed; one that is set aside settles with the
|
|
24
|
+
# invocation's failed attempts, and an :output_rejected trace names it.
|
|
25
|
+
module OutputIntegrity
|
|
26
|
+
include OutputRecovery
|
|
27
|
+
|
|
28
|
+
# What one invocation has tried so far.
|
|
29
|
+
Plan = Struct.new(:agent_class, :input, :output_schema, :json_schema, :repairs_left, :retried, :fell_back,
|
|
30
|
+
:output_limit, :calls) do
|
|
31
|
+
def shape = ProviderRequest::Shape.new(output_schema:, output_limit:)
|
|
32
|
+
end
|
|
33
|
+
private_constant :Plan
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# The agent's `output_schema`, else a static RubyLLM `schema` (a schema
|
|
38
|
+
# given as a block is resolved by RubyLLM per chat, and not checked).
|
|
39
|
+
def effective_output_schema(agent_class)
|
|
40
|
+
return agent_class.output_schema if agent_class.output_schema
|
|
41
|
+
|
|
42
|
+
schema = agent_class.schema if agent_class.respond_to?(:schema)
|
|
43
|
+
schema unless schema.is_a?(Proc)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def output_plan(agent_class, prepared_input, output_schema)
|
|
47
|
+
Plan.new(agent_class:, input: prepared_input, output_schema:, repairs_left: agent_class.output_repairs,
|
|
48
|
+
json_schema: output_schema && Providers::SchemaConstraints.json_schema(output_schema), calls: 1)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# nil when the output is accepted; otherwise a callable that makes the
|
|
52
|
+
# next attempt. Raises the output failure when nothing is left to try.
|
|
53
|
+
def output_follow_up(plan, attempt, result)
|
|
54
|
+
failure = stop_failure(plan, attempt, result) || content_failure(plan, attempt, result)
|
|
55
|
+
return unless failure
|
|
56
|
+
|
|
57
|
+
follow_up = recovery(plan, attempt, result, failure)
|
|
58
|
+
record_rejection(plan, attempt, failure, follow_up)
|
|
59
|
+
raise failure unless follow_up
|
|
60
|
+
|
|
61
|
+
follow_up
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def stop_failure(plan, attempt, result)
|
|
65
|
+
case Providers::StopReason.classify(stop_reason_of(attempt))
|
|
66
|
+
when :refused then output_failure(OutputRefused, plan, attempt, result)
|
|
67
|
+
when :truncated
|
|
68
|
+
accepted = plan.agent_class.on_truncation[:policy] == :accept
|
|
69
|
+
output_failure(OutputTruncated, plan, attempt, result) unless accepted
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# After the agent's `after_completion`, which sees every response the
|
|
74
|
+
# provider did not refuse or cut off. A blank output is left to
|
|
75
|
+
# BlankAgentOutputError.
|
|
76
|
+
def content_failure(plan, attempt, result)
|
|
77
|
+
result.content = run_after_completion(plan.agent_class, result.content, @context)
|
|
78
|
+
return if blank_agent_output?(result.content)
|
|
79
|
+
|
|
80
|
+
parse_structured_output!(plan, result)
|
|
81
|
+
problems = output_problems(plan, result.content)
|
|
82
|
+
output_failure(OutputInvalid, plan, attempt, result, problems) unless problems.empty?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# A structured output that reached Smith as JSON text (its schema
|
|
86
|
+
# parsing bypassed) is read as RubyLLM reads a schema response.
|
|
87
|
+
def parse_structured_output!(plan, result)
|
|
88
|
+
return unless (plan.output_schema || plan.agent_class.output_contract) && result.content.is_a?(String)
|
|
89
|
+
|
|
90
|
+
parsed = JSON.parse(result.content)
|
|
91
|
+
result.content = parsed if parsed.is_a?(Hash)
|
|
92
|
+
rescue JSON::ParserError
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def output_problems(plan, content)
|
|
97
|
+
contract = plan.agent_class.output_contract
|
|
98
|
+
return [] unless plan.output_schema || contract
|
|
99
|
+
return ["/: is not a JSON object"] unless content.is_a?(Hash)
|
|
100
|
+
|
|
101
|
+
schema = plan.json_schema && Providers::SchemaConstraints.problems(plan.json_schema["schema"], content)
|
|
102
|
+
Array(schema) + OutputContractCheck.problems(contract, content)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# A billed response that is not the invocation's output settles with
|
|
106
|
+
# its failed attempts.
|
|
107
|
+
def settle_rejected(agent_result)
|
|
108
|
+
add_failed_agent_results([agent_result])
|
|
109
|
+
Thread.current[:smith_last_agent_result] = nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def record_rejection(plan, attempt, failure, follow_up)
|
|
113
|
+
Smith::Trace.record(
|
|
114
|
+
type: :output_rejected,
|
|
115
|
+
data: { agent: plan.agent_class.register_as, attempt_id: attempt.attempt_id,
|
|
116
|
+
error_class: failure.class.name, stop_reason: failure.stop_reason,
|
|
117
|
+
problem_count: failure.problems.length, follow_up: follow_up ? next_step(failure) : :none }
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def next_step(failure)
|
|
122
|
+
case failure
|
|
123
|
+
when OutputRefused then :fallback
|
|
124
|
+
when OutputTruncated then :retry
|
|
125
|
+
else :repair
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def stop_reason_of(attempt) = attempt.completion.response_facts[:stop_reason]
|
|
130
|
+
|
|
131
|
+
def output_failure(error_class, plan, attempt, result, problems = [])
|
|
132
|
+
error_class.new(agent_name: plan.agent_class.register_as, model_used: result.model_used,
|
|
133
|
+
stop_reason: stop_reason_of(attempt), problems:)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Agent
|
|
5
|
+
# What an agent does with a response that cannot be its output:
|
|
6
|
+
#
|
|
7
|
+
# on_truncation :fail # the default: OutputTruncated
|
|
8
|
+
# on_truncation :accept # keep the cut-off output
|
|
9
|
+
# on_truncation retry_with: { max_tokens: 32_000 }
|
|
10
|
+
# on_refusal :fail # the default: OutputRefused
|
|
11
|
+
# on_refusal fallback: "anthropic/claude-opus-5-5"
|
|
12
|
+
# output_contract ReviewContract # checks an output_schema agent's Hash
|
|
13
|
+
# output_repairs 1 # the default; 0 turns repair off
|
|
14
|
+
#
|
|
15
|
+
# A retry, a fallback and each repair are one more provider call, billed
|
|
16
|
+
# like any other.
|
|
17
|
+
module OutputPolicy
|
|
18
|
+
MAX_REPAIRS = 3
|
|
19
|
+
FAIL = { policy: :fail }.freeze
|
|
20
|
+
private_constant :FAIL
|
|
21
|
+
|
|
22
|
+
def on_truncation(policy = nil, retry_with: nil)
|
|
23
|
+
return @on_truncation || FAIL if policy.nil? && retry_with.nil?
|
|
24
|
+
|
|
25
|
+
@on_truncation = if one_of!("on_truncation", policy, %i[fail accept], retry_with)
|
|
26
|
+
{ policy: :retry, max_tokens: retry_limit(retry_with) }.freeze
|
|
27
|
+
else
|
|
28
|
+
{ policy: }.freeze
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def on_refusal(policy = nil, fallback: nil)
|
|
33
|
+
return @on_refusal || FAIL if policy.nil? && fallback.nil?
|
|
34
|
+
|
|
35
|
+
@on_refusal = if one_of!("on_refusal", policy, %i[fail], fallback)
|
|
36
|
+
{ policy: :fallback, model: refusal_fallback(fallback) }.freeze
|
|
37
|
+
else
|
|
38
|
+
FAIL
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Any object (or a class whose instances are such objects) whose
|
|
43
|
+
# `call(hash)` returns a result answering `success?` and `errors`, as
|
|
44
|
+
# a dry-validation contract does.
|
|
45
|
+
def output_contract(contract = nil)
|
|
46
|
+
return @output_contract if contract.nil?
|
|
47
|
+
|
|
48
|
+
checker = contract.is_a?(Class) ? contract.new : contract
|
|
49
|
+
raise ArgumentError, "output_contract must respond to call" unless checker.respond_to?(:call)
|
|
50
|
+
|
|
51
|
+
@output_contract = checker
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def output_repairs(count = nil)
|
|
55
|
+
return @output_repairs || 1 if count.nil?
|
|
56
|
+
unless count.is_a?(Integer) && count.between?(0, MAX_REPAIRS)
|
|
57
|
+
raise ArgumentError, "output_repairs must be an Integer from 0 to #{MAX_REPAIRS}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
@output_repairs = count
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# True when the option is given; otherwise the policy must be one of
|
|
66
|
+
# `policies`.
|
|
67
|
+
def one_of!(macro, policy, policies, option)
|
|
68
|
+
raise ArgumentError, "#{macro} takes a policy or an option, not both" if policy && option
|
|
69
|
+
return true if option
|
|
70
|
+
return false if policies.include?(policy)
|
|
71
|
+
|
|
72
|
+
raise ArgumentError, "#{macro} takes #{policies.map(&:inspect).join(" or ")}, or an option"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def retry_limit(options)
|
|
76
|
+
limit = options.is_a?(Hash) && options.keys == [:max_tokens] ? options[:max_tokens] : nil
|
|
77
|
+
return limit if limit.is_a?(Integer) && limit.positive?
|
|
78
|
+
|
|
79
|
+
raise ArgumentError, "on_truncation retry_with: takes { max_tokens: a positive Integer }"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def refusal_fallback(model)
|
|
83
|
+
reference = ModelReference.coerce(model)
|
|
84
|
+
return reference if reference.provider
|
|
85
|
+
|
|
86
|
+
raise ArgumentError, "on_refusal fallback: must name a provider, as in \"anthropic/<model>\""
|
|
87
|
+
rescue Dry::Struct::Error => e
|
|
88
|
+
raise ArgumentError, "invalid on_refusal fallback: #{e.message}"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Agent
|
|
7
|
+
# The follow-up call an output failure may get (OutputIntegrity): a
|
|
8
|
+
# refusal fallback, a truncation retry, or a repair, each within the
|
|
9
|
+
# agent's settings and never once a tool has run in the step, once the
|
|
10
|
+
# call has spent what its step reserved, or past MAX_CALLS calls.
|
|
11
|
+
module OutputRecovery
|
|
12
|
+
MAX_CALLS = 3 + OutputPolicy::MAX_REPAIRS
|
|
13
|
+
REPAIR_NOTE = "Your previous reply could not be used as the required output:\n%s\n" \
|
|
14
|
+
"Reply again with output that fixes these problems."
|
|
15
|
+
REPAIR_TASK = "Reply with the required output."
|
|
16
|
+
# Why a streamed step's rejected text is withdrawn (Smith::Streaming).
|
|
17
|
+
STREAM_RESETS = { OutputRefused => :fallback, OutputTruncated => :truncation }.freeze
|
|
18
|
+
private_constant :MAX_CALLS, :REPAIR_NOTE, :REPAIR_TASK, :STREAM_RESETS
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def recovery(plan, attempt, result, failure)
|
|
23
|
+
return if tools_ran? || plan.calls >= MAX_CALLS || budget_spent?(result)
|
|
24
|
+
|
|
25
|
+
case failure
|
|
26
|
+
when OutputRefused then refusal_recovery(plan, attempt, failure)
|
|
27
|
+
when OutputTruncated then truncation_recovery(plan, attempt, failure)
|
|
28
|
+
else repair_recovery(plan, attempt, result, failure)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def tools_ran? = Tool.current_tool_execution_tracker&.started? || false
|
|
33
|
+
|
|
34
|
+
def refusal_recovery(plan, attempt, failure)
|
|
35
|
+
fallback = plan.agent_class.on_refusal[:model]
|
|
36
|
+
return if fallback.nil? || plan.fell_back || fallback.same_candidate?(attempt.model_reference)
|
|
37
|
+
|
|
38
|
+
plan.fell_back = true
|
|
39
|
+
-> { follow_up_attempt(plan, attempt, fallback, failure) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def truncation_recovery(plan, attempt, failure)
|
|
43
|
+
limit = retry_limit(plan, attempt)
|
|
44
|
+
return unless limit
|
|
45
|
+
|
|
46
|
+
plan.retried = true
|
|
47
|
+
plan.output_limit = limit
|
|
48
|
+
-> { follow_up_attempt(plan, attempt, attempt.model_reference, failure) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The larger limit to retry with, or nil: no retry is declared or left,
|
|
52
|
+
# the provider has no output limit Smith can set, the response ran out
|
|
53
|
+
# of context rather than output, or the limit is not larger than the
|
|
54
|
+
# one the request already had.
|
|
55
|
+
def retry_limit(plan, attempt)
|
|
56
|
+
return unless retry_allowed?(plan, attempt)
|
|
57
|
+
|
|
58
|
+
limit = plan.agent_class.on_truncation[:max_tokens]
|
|
59
|
+
current = plan.output_limit || Providers::OutputLimit.current(attempt.model_reference.provider,
|
|
60
|
+
agent_params(plan.agent_class),
|
|
61
|
+
attempt.model_reference)
|
|
62
|
+
limit if current.nil? || limit > current
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def retry_allowed?(plan, attempt)
|
|
66
|
+
plan.agent_class.on_truncation[:policy] == :retry && !plan.retried &&
|
|
67
|
+
Providers::OutputLimit.supported?(attempt.model_reference.provider) &&
|
|
68
|
+
stop_reason_of(attempt) != "model_context_window_exceeded"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def agent_params(agent_class)
|
|
72
|
+
params = agent_class.params if agent_class.respond_to?(:params)
|
|
73
|
+
params.is_a?(Hash) ? params : {}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def repair_recovery(plan, attempt, result, failure)
|
|
77
|
+
return unless plan.repairs_left.positive?
|
|
78
|
+
|
|
79
|
+
plan.repairs_left -= 1
|
|
80
|
+
plan.input = repair_input(plan.input, result.content, failure.problems)
|
|
81
|
+
-> { follow_up_attempt(plan, attempt, attempt.model_reference, failure) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The step's messages as the provider got them (with any continuation
|
|
85
|
+
# turn), the rejected reply as the assistant's, and the problems.
|
|
86
|
+
def repair_input(input, content, problems)
|
|
87
|
+
note = format(REPAIR_NOTE, problems.map { |problem| "- #{problem}" }.join("\n"))
|
|
88
|
+
messages = provider_safe_prepared_input(Array(input))
|
|
89
|
+
messages = [{ role: :user, content: REPAIR_TASK }] if messages.empty?
|
|
90
|
+
messages + [{ role: :assistant, content: rejected_text(content) }, { role: :user, content: note }]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def rejected_text(content)
|
|
94
|
+
content.is_a?(String) ? content : JSON.generate(content)
|
|
95
|
+
rescue JSON::GeneratorError, Encoding::UndefinedConversionError
|
|
96
|
+
content.to_s
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def follow_up_attempt(plan, previous, model_reference, pending)
|
|
100
|
+
check_deadline!
|
|
101
|
+
plan.calls += 1
|
|
102
|
+
withdraw_streamed(STREAM_RESETS.fetch(pending.class, :repair))
|
|
103
|
+
attempt = attempt_model(plan.agent_class, plan.input, model_reference,
|
|
104
|
+
shape: plan.shape, attempt_index: previous.attempt_index.to_i + 1)
|
|
105
|
+
return attempt if attempt.success?
|
|
106
|
+
|
|
107
|
+
fail_follow_up(plan, attempt, pending)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def fail_follow_up(plan, attempt, pending)
|
|
111
|
+
account_failed_attempt(attempt.error, attempt.model_reference, plan.agent_class, attempt_id: attempt.attempt_id)
|
|
112
|
+
raise pending, cause: attempt.error
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Whether the responses so far have reached what the call's step
|
|
116
|
+
# reserved in any budget dimension.
|
|
117
|
+
def budget_spent?(result)
|
|
118
|
+
reservation = Thread.current[:smith_call_reservation]
|
|
119
|
+
return false unless reservation
|
|
120
|
+
|
|
121
|
+
spent = extract_actuals([*Array(Thread.current[:smith_failed_agent_results]), result])
|
|
122
|
+
reservation.amounts.any? do |dimension, amount|
|
|
123
|
+
used = Workflow::BudgetIntegration::COST_DIMENSIONS.include?(dimension) ? spent[:cost] : spent[:tokens]
|
|
124
|
+
used >= amount
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -4,6 +4,7 @@ require "dry-struct"
|
|
|
4
4
|
|
|
5
5
|
require_relative "../types"
|
|
6
6
|
require_relative "model_reference"
|
|
7
|
+
require_relative "provider_usage"
|
|
7
8
|
|
|
8
9
|
module Smith
|
|
9
10
|
class Agent
|
|
@@ -17,13 +18,25 @@ module Smith
|
|
|
17
18
|
# completion call started.
|
|
18
19
|
attribute :attempt_id, Types::String.optional.default(nil)
|
|
19
20
|
attribute :duration_ms, Types::Integer.optional.default(nil)
|
|
21
|
+
# The candidate's position in the model chain, the registered name of
|
|
22
|
+
# the agent that made the attempt, and the provider-reported usage its
|
|
23
|
+
# usage entries record (nil when the provider reported none).
|
|
24
|
+
attribute :attempt_index, Types::Integer.optional.default(nil)
|
|
25
|
+
attribute :agent_name, (Types::Symbol | Types::String).optional.default(nil)
|
|
26
|
+
attribute :usage, Types.Instance(ProviderUsage).optional.default(nil)
|
|
27
|
+
# True for a batch result that answered a request other than the one
|
|
28
|
+
# the call renders now (BatchExchange); nil otherwise.
|
|
29
|
+
attribute :request_changed, Types::Bool.optional.default(nil)
|
|
30
|
+
# True for a response a host's response memo kept (ResponseMemo): the
|
|
31
|
+
# call sent nothing.
|
|
32
|
+
attribute :memo, Types::Bool.optional.default(nil)
|
|
20
33
|
|
|
21
|
-
def self.success(completion:, model_reference:,
|
|
22
|
-
new(completion:, model_reference:, error: nil,
|
|
34
|
+
def self.success(completion:, model_reference:, **facts)
|
|
35
|
+
new(completion:, model_reference:, error: nil, **facts)
|
|
23
36
|
end
|
|
24
37
|
|
|
25
|
-
def self.failure(error:, model_reference:,
|
|
26
|
-
new(completion: nil, model_reference:, error:,
|
|
38
|
+
def self.failure(error:, model_reference:, **facts)
|
|
39
|
+
new(completion: nil, model_reference:, error:, **facts)
|
|
27
40
|
end
|
|
28
41
|
|
|
29
42
|
def success?
|