smith-agents 0.4.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.
Files changed (115) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +139 -0
  3. data/CODE_OF_CONDUCT.md +128 -0
  4. data/LICENSE +21 -0
  5. data/README.md +226 -0
  6. data/Rakefile +14 -0
  7. data/UPSTREAM_PROPOSAL.md +141 -0
  8. data/docs/CONFIGURATION.md +123 -0
  9. data/docs/PATTERNS.md +492 -0
  10. data/docs/PERSISTENCE.md +169 -0
  11. data/docs/TOOLS_AND_GUARDRAILS.md +140 -0
  12. data/docs/workflow_claim.md +58 -0
  13. data/exe/smith +7 -0
  14. data/lib/generators/smith/install/install_generator.rb +22 -0
  15. data/lib/generators/smith/install/templates/smith.rb.tt +44 -0
  16. data/lib/smith/agent/lifecycle.rb +264 -0
  17. data/lib/smith/agent/registry.rb +128 -0
  18. data/lib/smith/agent.rb +259 -0
  19. data/lib/smith/artifacts/file.rb +59 -0
  20. data/lib/smith/artifacts/memory.rb +75 -0
  21. data/lib/smith/artifacts/scoped_store.rb +29 -0
  22. data/lib/smith/artifacts.rb +5 -0
  23. data/lib/smith/budget/ledger.rb +42 -0
  24. data/lib/smith/budget.rb +5 -0
  25. data/lib/smith/cli.rb +82 -0
  26. data/lib/smith/context/observation_masking.rb +19 -0
  27. data/lib/smith/context/session.rb +42 -0
  28. data/lib/smith/context/state_injection.rb +24 -0
  29. data/lib/smith/context.rb +61 -0
  30. data/lib/smith/doctor/check.rb +12 -0
  31. data/lib/smith/doctor/checks/baseline.rb +84 -0
  32. data/lib/smith/doctor/checks/configuration.rb +56 -0
  33. data/lib/smith/doctor/checks/durability.rb +103 -0
  34. data/lib/smith/doctor/checks/live.rb +55 -0
  35. data/lib/smith/doctor/checks/models_registry.rb +66 -0
  36. data/lib/smith/doctor/checks/openai_api_mode.rb +51 -0
  37. data/lib/smith/doctor/checks/persistence.rb +99 -0
  38. data/lib/smith/doctor/checks/persistence_capabilities.rb +60 -0
  39. data/lib/smith/doctor/checks/persistence_registry.rb +82 -0
  40. data/lib/smith/doctor/checks/rails.rb +39 -0
  41. data/lib/smith/doctor/checks/serialization.rb +78 -0
  42. data/lib/smith/doctor/installer.rb +103 -0
  43. data/lib/smith/doctor/printer.rb +62 -0
  44. data/lib/smith/doctor/report.rb +39 -0
  45. data/lib/smith/doctor.rb +53 -0
  46. data/lib/smith/errors.rb +191 -0
  47. data/lib/smith/event.rb +11 -0
  48. data/lib/smith/events/.keep +0 -0
  49. data/lib/smith/events/bus.rb +60 -0
  50. data/lib/smith/events/step_completed.rb +11 -0
  51. data/lib/smith/events/subscription.rb +24 -0
  52. data/lib/smith/events.rb +5 -0
  53. data/lib/smith/guardrails/runner.rb +44 -0
  54. data/lib/smith/guardrails/url_verifier.rb +7 -0
  55. data/lib/smith/guardrails.rb +35 -0
  56. data/lib/smith/models/inference.rb +199 -0
  57. data/lib/smith/models/normalizer.rb +186 -0
  58. data/lib/smith/models/profile.rb +39 -0
  59. data/lib/smith/models.rb +132 -0
  60. data/lib/smith/persistence_adapters/active_record_store.rb +99 -0
  61. data/lib/smith/persistence_adapters/cache_store.rb +79 -0
  62. data/lib/smith/persistence_adapters/memory.rb +105 -0
  63. data/lib/smith/persistence_adapters/rails_cache.rb +20 -0
  64. data/lib/smith/persistence_adapters/redis_store.rb +136 -0
  65. data/lib/smith/persistence_adapters/retry.rb +42 -0
  66. data/lib/smith/persistence_adapters.rb +112 -0
  67. data/lib/smith/pricing.rb +65 -0
  68. data/lib/smith/providers/openai/responses.rb +315 -0
  69. data/lib/smith/providers/openai/routing.rb +67 -0
  70. data/lib/smith/providers/openai/tools_extensions.rb +106 -0
  71. data/lib/smith/railtie.rb +9 -0
  72. data/lib/smith/tasks/doctor.rake +38 -0
  73. data/lib/smith/tool/budget_enforcement.rb +33 -0
  74. data/lib/smith/tool/capability_builder.rb +18 -0
  75. data/lib/smith/tool/capture.rb +22 -0
  76. data/lib/smith/tool/compatibility.rb +72 -0
  77. data/lib/smith/tool/policy.rb +40 -0
  78. data/lib/smith/tool.rb +171 -0
  79. data/lib/smith/tools/think.rb +25 -0
  80. data/lib/smith/tools/url_fetcher.rb +16 -0
  81. data/lib/smith/tools/web_search.rb +17 -0
  82. data/lib/smith/tools.rb +5 -0
  83. data/lib/smith/trace/logger.rb +46 -0
  84. data/lib/smith/trace/memory.rb +53 -0
  85. data/lib/smith/trace/open_telemetry.rb +57 -0
  86. data/lib/smith/trace.rb +89 -0
  87. data/lib/smith/types.rb +16 -0
  88. data/lib/smith/version.rb +5 -0
  89. data/lib/smith/workflow/artifact_integration.rb +41 -0
  90. data/lib/smith/workflow/budget_integration.rb +105 -0
  91. data/lib/smith/workflow/claim.rb +118 -0
  92. data/lib/smith/workflow/data_volume_policy.rb +36 -0
  93. data/lib/smith/workflow/deadline_enforcement.rb +100 -0
  94. data/lib/smith/workflow/deterministic_execution.rb +53 -0
  95. data/lib/smith/workflow/deterministic_step.rb +57 -0
  96. data/lib/smith/workflow/dsl.rb +223 -0
  97. data/lib/smith/workflow/durability.rb +369 -0
  98. data/lib/smith/workflow/evaluator_optimizer.rb +220 -0
  99. data/lib/smith/workflow/event_integration.rb +24 -0
  100. data/lib/smith/workflow/execution.rb +127 -0
  101. data/lib/smith/workflow/execution_frame.rb +166 -0
  102. data/lib/smith/workflow/guardrail_integration.rb +40 -0
  103. data/lib/smith/workflow/nested_execution.rb +69 -0
  104. data/lib/smith/workflow/orchestrator_worker.rb +145 -0
  105. data/lib/smith/workflow/parallel.rb +50 -0
  106. data/lib/smith/workflow/parallel_execution.rb +75 -0
  107. data/lib/smith/workflow/persistence.rb +358 -0
  108. data/lib/smith/workflow/pipeline.rb +117 -0
  109. data/lib/smith/workflow/router.rb +53 -0
  110. data/lib/smith/workflow/transition.rb +208 -0
  111. data/lib/smith/workflow.rb +555 -0
  112. data/lib/smith.rb +254 -0
  113. data/script/profile_tool_results.rb +94 -0
  114. data/sig/smith.rbs +4 -0
  115. metadata +258 -0
@@ -0,0 +1,140 @@
1
+ # Tools and Guardrails
2
+
3
+ ## Tools
4
+
5
+ Smith tools extend RubyLLM tools with:
6
+
7
+ - privilege enforcement
8
+ - custom authorization
9
+ - tool guardrails
10
+ - deadline enforcement
11
+ - tool-call budgeting
12
+ - tracing
13
+ - result capture (workflow-scoped tool output collection)
14
+
15
+ Example:
16
+
17
+ ```ruby
18
+ class RefundCustomer < Smith::Tool
19
+ category :action
20
+
21
+ capabilities do
22
+ privilege :elevated
23
+ end
24
+
25
+ authorize do |context|
26
+ context[:account_id] && context[:role] == :elevated
27
+ end
28
+
29
+ def perform(context:, charge_id:, reason:)
30
+ # call your billing system here
31
+ { refunded: true, charge_id: charge_id, reason: reason }
32
+ end
33
+ end
34
+ ```
35
+
36
+ ### Tool Compatibility (provider-aware tool selection)
37
+
38
+ Tools can declare which provider/endpoint combinations they tolerate. `Smith::Models::Normalizer` consults this metadata at chat construction and drops incompatible tools rather than letting the provider reject the request. Tools without a declaration are universally compatible (preserves existing behavior).
39
+
40
+ ```ruby
41
+ class WebSearch < Smith::Tool
42
+ # Allowlist form: specific providers, plus an OpenAI endpoint constraint.
43
+ compatible_with :anthropic, :gemini, openai: :responses
44
+
45
+ def perform(query:)
46
+ # ...
47
+ end
48
+ end
49
+ ```
50
+
51
+ When `Smith.config.openai_api_mode = :auto` (the default) AND the tool requires `/v1/responses`, the normalizer instead sets `@params[:openai_api_mode] = :responses` so the routing prepend can dispatch via the Responses endpoint. When `:off`, the tool is dropped gracefully.
52
+
53
+ The compatibility spec is inherited by subclasses; subclasses can override by calling `compatible_with` again. The spec is consulted only by the Normalizer, so tools without a declaration retain their pre-refactor behavior.
54
+
55
+ ### Tool Result Capture
56
+
57
+ Tools can declare a `capture_result` block to collect structured data during workflow execution. Smith stores captured data on the workflow and exposes it on `RunResult#tool_results`. Smith does not interpret the payload — the host app owns all projection.
58
+
59
+ ```ruby
60
+ class WebSearch < Smith::Tool
61
+ capture_result do |kwargs, result|
62
+ { query: kwargs[:query], urls: extract_urls(result) }
63
+ end
64
+
65
+ def perform(query:)
66
+ # search implementation
67
+ end
68
+ end
69
+ ```
70
+
71
+ After workflow execution:
72
+
73
+ ```ruby
74
+ result = MyWorkflow.run_persisted!(key: "search:123", context: { topic: "AI" })
75
+ result.tool_results
76
+ # => [{ tool: "web_search", captured: { query: "AI trends", urls: ["https://..."] } }]
77
+ ```
78
+
79
+ Captured tool results survive persistence — they are included in `to_state` and restored via `from_state`.
80
+
81
+ `tool_results` is designed for compact structured evidence (URLs, metadata, refs). Hosts should avoid storing large raw payloads there. If large tool outputs are needed, use artifacts and capture refs or metadata instead.
82
+
83
+ You can still use RubyLLM agent tool wiring on your agents:
84
+
85
+ ```ruby
86
+ class RefundAgent < Smith::Agent
87
+ register_as :refund_agent
88
+ model "gpt-4.1-nano"
89
+ tools RefundCustomer
90
+ end
91
+ ```
92
+
93
+ ## Guardrails
94
+
95
+ Guardrails can be attached at either the workflow level or the agent level.
96
+
97
+ Workflow guardrails run before agent guardrails for inputs, and before agent guardrails for outputs as well.
98
+
99
+ Example:
100
+
101
+ ```ruby
102
+ class SupportGuardrails < Smith::Guardrails
103
+ def require_input(payload)
104
+ raise "missing input" if payload.nil?
105
+ end
106
+
107
+ def sanitize_output(payload)
108
+ raise "empty response" if payload.nil?
109
+ end
110
+
111
+ def require_ticket(kwargs)
112
+ raise "ticket_id required" unless kwargs.dig(:context, :ticket_id)
113
+ end
114
+
115
+ input :require_input
116
+ output :sanitize_output
117
+ tool :require_ticket, on: [:refund_customer]
118
+ end
119
+ ```
120
+
121
+ Attach them like this:
122
+
123
+ ```ruby
124
+ class GuardedAgent < Smith::Agent
125
+ register_as :guarded_agent
126
+ model "gpt-4.1-nano"
127
+ guardrails SupportGuardrails
128
+ end
129
+
130
+ class GuardedWorkflow < Smith::Workflow
131
+ guardrails SupportGuardrails
132
+ initial_state :idle
133
+ state :done
134
+
135
+ transition :finish, from: :idle, to: :done do
136
+ execute :guarded_agent
137
+ end
138
+ end
139
+ ```
140
+
@@ -0,0 +1,58 @@
1
+ # Smith::Workflow::Claim
2
+
3
+ ActiveRecord-aware atomic claim helper. Consolidates the SELECT FOR UPDATE + status-transition pattern hosts otherwise reinvent in every per-record Execution wrapper.
4
+
5
+ ActiveRecord is loaded lazily. `lib/smith/workflow/claim.rb` does NOT const-reference `::ActiveRecord` at module load — both `.atomic` and `.cas` raise `Smith::Workflow::Claim::AdapterUnavailable` only when invoked without AR present. Smith stays gem-load-time decoupled from AR.
6
+
7
+ ## `.atomic` — AASM event path
8
+
9
+ ```ruby
10
+ Smith::Workflow::Claim.atomic(
11
+ ResearchSession,
12
+ id: session.id,
13
+ from_statuses: %w[queued],
14
+ transition_via: :mark_processing!,
15
+ terminal_statuses: %w[processing ready failed],
16
+ transaction_owner: ApplicationRecord
17
+ )
18
+ ```
19
+
20
+ Wraps the transition in `transaction_owner.transaction` (defaults to `model_class`). Inside the block: `lock.find(id)`, status check, then `record.public_send(transition_via)` — AASM events fire normally with all callbacks intact.
21
+
22
+ - Returns the reloaded record on success.
23
+ - Returns `nil` when current status is in `terminal_statuses` (e.g. a duplicate enqueue arriving after the original already finished).
24
+ - Raises `Smith::Workflow::Claim::UnexpectedStatus` when status is outside `from_statuses ∪ terminal_statuses` (default behavior; pass `on_unexpected_status: :ignore` or `:log` to soften).
25
+ - Raises `ArgumentError` when `transition_via:` is nil AND the model responds to `.aasm` — prevents silent AASM-callback drops.
26
+
27
+ When using cross-model transactions (e.g. AR models inherit from `ApplicationRecord`), pass `transaction_owner: ApplicationRecord` so the existing transaction scope is preserved.
28
+
29
+ ## `.cas` — single-statement CAS path
30
+
31
+ ```ruby
32
+ Smith::Workflow::Claim.cas(
33
+ Post,
34
+ id: post.id,
35
+ from_statuses: %w[draft scheduled failed],
36
+ to_status: "processing"
37
+ )
38
+ ```
39
+
40
+ Single `update_all` with `where(status: from_statuses)`. Returns the reloaded record or `nil` if rowcount is zero. Stamps `updated_at` via the injected `now:` lambda (defaults to `-> { Time.now.utc }`).
41
+
42
+ - Does NOT invoke AASM events. AASM callbacks are skipped by design — this path is for non-AASM CAS sites (e.g. `Posts::Publish` style).
43
+ - ActiveRecord 8.x increments `lock_version` on `update_all` when the column is present. Consumer code that depends on `lock_version` should account for this — `.cas` makes no promise about lock_version semantics.
44
+
45
+ ## Idempotency contract
46
+
47
+ For both strategies, calling twice with the same `id` (no concurrency) returns the claimed record on the first call and `nil` on the second, because the status is no longer in `from_statuses`. No explicit advisory lock is held.
48
+
49
+ ## When to use which
50
+
51
+ - `.atomic` — the model uses AASM (or you want guards/callbacks/auxiliary timestamps to fire).
52
+ - `.cas` — the model does NOT use AASM AND you want a single-statement update.
53
+
54
+ If the model has AASM and you want to skip events, call `.cas` explicitly; if you call `.atomic` without `transition_via:` on an AASM model, you'll get an `ArgumentError` so the silent-callback-drop is impossible.
55
+
56
+ ## Testing
57
+
58
+ Specs that exercise the AR strategies are tagged `:ar` and excluded from the default suite. The full suite under `SMITH_AR_SPECS=1` boots an in-memory SQLite database and the `ClaimableRecord` fixture model. Default `bundle exec rspec` runs 816 examples (Claim load-hygiene only); `SMITH_AR_SPECS=1 bundle exec rspec` runs 831 (adds 15 AR-tagged Claim specs).
data/exe/smith ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "smith"
5
+ require "smith/cli"
6
+
7
+ exit Smith::CLI.new(ARGV).run
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module Smith
6
+ class InstallGenerator < Rails::Generators::Base
7
+ source_root File.expand_path("templates", __dir__)
8
+
9
+ def create_smith_initializer
10
+ template "smith.rb.tt", "config/initializers/smith.rb"
11
+ end
12
+
13
+ def show_next_steps
14
+ say ""
15
+ say "Smith installed. Next steps:", :green
16
+ say " 1. Configure RubyLLM in config/initializers/ruby_llm.rb"
17
+ say " 2. Run: bin/rails smith:doctor"
18
+ say " 3. Define your first agent and workflow"
19
+ say ""
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ Smith.configure do |config|
4
+ config.logger = Rails.logger
5
+
6
+ # Host durability verification / persistence adapter options:
7
+ # config.persistence_adapter = :rails_cache
8
+ # config.persistence_options = { namespace: "smith" }
9
+ #
10
+ # config.persistence_adapter = :solid_cache
11
+ # config.persistence_options = { namespace: "smith" }
12
+ #
13
+ # config.persistence_adapter = :redis
14
+ # config.persistence_options = {
15
+ # redis: Redis.new(url: ENV.fetch("REDIS_URL")),
16
+ # namespace: "smith"
17
+ # }
18
+ #
19
+ # config.persistence_adapter = :active_record
20
+ # config.persistence_options = {
21
+ # model: WorkflowState,
22
+ # key_column: :key,
23
+ # payload_column: :payload
24
+ # }
25
+ #
26
+ # Custom adapters are also supported if they implement:
27
+ # store(key, payload)
28
+ # fetch(key)
29
+ # delete(key)
30
+
31
+ # Artifact storage for large outputs
32
+ config.artifact_store = Smith::Artifacts::Memory.new
33
+
34
+ # Trace adapter (Smith::Trace::Memory, Smith::Trace::Logger, Smith::Trace::OpenTelemetry)
35
+ config.trace_adapter = Smith::Trace::Logger
36
+
37
+ # Best-known model-call cost tracking (optional)
38
+ # config.pricing = {
39
+ # "gpt-4.1-nano" => {
40
+ # input_cost_per_token: 0.0000001,
41
+ # output_cost_per_token: 0.0000004
42
+ # }
43
+ # }
44
+ end
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Agent
5
+ module Lifecycle
6
+ TRANSIENT_ERRORS = [
7
+ RubyLLM::ServerError, RubyLLM::ServiceUnavailableError,
8
+ RubyLLM::OverloadedError, RubyLLM::RateLimitError
9
+ ].freeze
10
+
11
+ private
12
+
13
+ def run_after_completion(agent_class, result, context)
14
+ return result unless agent_class.method_defined?(:after_completion)
15
+
16
+ instance = agent_class.allocate
17
+ instance.after_completion(result, context)
18
+ end
19
+
20
+ def invoke_agent(agent_class, prepared_input)
21
+ check_deadline!
22
+ response, model_used = complete_with_provider(agent_class, prepared_input)
23
+ snapshot_and_finalize(agent_class, response, model_used)
24
+ end
25
+
26
+ # Returns [response, model_used] as local data — no shared mutable
27
+ # state. Previously this method set `@last_attempt_model` on the
28
+ # workflow instance and `snapshot_and_finalize` read it back; under
29
+ # parallel fan-out, two branches sharing the workflow could race
30
+ # and attribute the wrong model to the wrong response. Local data
31
+ # eliminates the race entirely.
32
+ def complete_with_provider(agent_class, prepared_input)
33
+ models = build_model_chain(agent_class)
34
+
35
+ models.each_with_index do |model_id, index|
36
+ check_deadline! if index.positive?
37
+ response = attempt_model(agent_class, prepared_input, model_id)
38
+ return [response, model_id]
39
+ rescue Smith::Error
40
+ raise
41
+ rescue StandardError => e
42
+ account_failed_attempt(e, model_id, agent_class)
43
+ raise Smith::AgentError, e.message unless fallback_eligible?(e) && index < models.length - 1
44
+ end
45
+ end
46
+
47
+ def build_model_chain(agent_class)
48
+ primary = if agent_class.respond_to?(:model_block) && agent_class.model_block
49
+ resolve_dynamic_model(agent_class)
50
+ else
51
+ agent_class.chat_kwargs[:model]
52
+ end
53
+ fallbacks = agent_class.fallback_models || []
54
+ [primary, *fallbacks].compact
55
+ end
56
+
57
+ # Evaluates a block-form `model` declaration with the workflow's
58
+ # @context (Hash, defaults to {} when uninitialized). The block
59
+ # must return a non-empty string model id; any other value
60
+ # surfaces as Smith::AgentError so the workflow's failure handler
61
+ # treats it as a step failure rather than a silent miss.
62
+ def resolve_dynamic_model(agent_class)
63
+ result = agent_class.model_block.call(@context || {})
64
+ return result if result.is_a?(String) && !result.empty?
65
+
66
+ raise Smith::AgentError,
67
+ "model block for #{agent_class} must return a non-empty string; got #{result.inspect}"
68
+ end
69
+
70
+ def attempt_model(agent_class, prepared_input, model_id)
71
+ chat = agent_class.chat(model: model_id, **bridge_workflow_inputs(agent_class))
72
+ add_prepared_input(chat, prepared_input)
73
+ chat = chat.with_schema(agent_class.output_schema) if agent_class.output_schema
74
+ chat.complete
75
+ end
76
+
77
+ # Bridges declared agent `inputs` from the workflow's @context Hash
78
+ # to the agent invocation kwargs, so block-form RubyLLM DSLs (tools,
79
+ # instructions, params, headers, schema) can access workflow-context
80
+ # data via bare method calls on `self` inside the block (RubyLLM
81
+ # invokes these via `runtime.instance_exec(&block)`, exposing each
82
+ # declared input as a singleton method on the runtime object).
83
+ # Smith's own `model` block-form already receives @context directly
84
+ # via `block.call(@context)`; this bridge gives runtime_context the
85
+ # same surface for the RubyLLM-owned blocks.
86
+ #
87
+ # Bridges ONLY user-declared inputs — reserved names
88
+ # (Smith::Agent::RESERVED_INPUT_NAMES: model_id, provider,
89
+ # endpoint_mode) are auto-injected by Smith::Agent.chat from the
90
+ # resolved profile, NOT from @context. The slice prevents the bridge
91
+ # from accidentally passing through stale or wrong values that
92
+ # happen to live in @context under those keys.
93
+ #
94
+ # Contract: declared inputs are ALWAYS passed (with nil when absent
95
+ # from @context). The declaration is the contract — `inputs :form_kind`
96
+ # promises that `form_kind` will be a callable singleton method on
97
+ # the runtime regardless of whether @context happens to have a value.
98
+ # This eliminates `respond_to?` defensiveness in agent blocks and
99
+ # mirrors the silent-nil semantics agent authors get from `ctx[:k]`
100
+ # in the model block. Non-Hash @context short-circuits.
101
+ def bridge_workflow_inputs(agent_class)
102
+ return {} unless @context.is_a?(Hash)
103
+
104
+ declared = agent_class.inputs || []
105
+ user_declared = declared - Smith::Agent::RESERVED_INPUT_NAMES
106
+ user_declared.each_with_object({}) do |name, kwargs|
107
+ kwargs[name] = @context[name]
108
+ end
109
+ end
110
+
111
+ def add_prepared_input(chat, prepared_input)
112
+ return unless prepared_input
113
+
114
+ system_messages, other_messages = prepared_input.partition do |message|
115
+ message_role(message) == :system
116
+ end
117
+
118
+ merge_system_messages!(chat, system_messages) if system_messages.any?
119
+ other_messages.each { |message| chat.add_message(message) }
120
+ end
121
+
122
+ def merge_system_messages!(chat, prepared_system_messages)
123
+ return prepared_system_messages.each { |message| chat.add_message(message) } unless chat.respond_to?(:messages)
124
+
125
+ existing_system_contents = chat.messages.filter_map do |message|
126
+ message.content if message_role(message) == :system
127
+ end
128
+ prepared_system_contents = prepared_system_messages.filter_map do |message|
129
+ message_content(message)
130
+ end
131
+
132
+ combined_contents = existing_system_contents + prepared_system_contents
133
+ return if combined_contents.empty?
134
+ return prepared_system_messages.each { |message| chat.add_message(message) } unless combined_contents.all?(String)
135
+
136
+ if chat.respond_to?(:with_instructions)
137
+ chat.with_instructions(combined_contents.join("\n\n"))
138
+ else
139
+ prepared_system_messages.each { |message| chat.add_message(message) }
140
+ end
141
+ end
142
+
143
+ def message_role(message)
144
+ if message.respond_to?(:role)
145
+ message.role&.to_sym
146
+ else
147
+ message[:role]&.to_sym
148
+ end
149
+ end
150
+
151
+ def message_content(message)
152
+ if message.respond_to?(:content)
153
+ message.content
154
+ else
155
+ message[:content]
156
+ end
157
+ end
158
+
159
+ def fallback_eligible?(error)
160
+ TRANSIENT_ERRORS.any? { |klass| error.is_a?(klass) } ||
161
+ error.is_a?(Faraday::TimeoutError) ||
162
+ error.is_a?(Faraday::ConnectionFailed)
163
+ end
164
+
165
+ # `agent_class` is now a parameter (was previously implicit via
166
+ # `@last_attempt_model`-only path). The caller (`complete_with_provider`)
167
+ # has the local already, so no shared mutable state is needed.
168
+ # Records the failed attempt's tokens via the unified `record_usage`
169
+ # helper, marking the entry as `:failed_attempt`.
170
+ def account_failed_attempt(error, model_id, agent_class)
171
+ return unless error.respond_to?(:input_tokens) && error.respond_to?(:output_tokens)
172
+
173
+ input = error.input_tokens
174
+ output = error.output_tokens
175
+ return unless input.is_a?(Integer) && output.is_a?(Integer)
176
+
177
+ cost = Smith::Pricing.compute_cost(model: model_id, input_tokens: input, output_tokens: output)
178
+ agent_result = Workflow::AgentResult.new(
179
+ content: nil, input_tokens: input, output_tokens: output, cost: cost, model_used: model_id
180
+ )
181
+ record_usage(agent_class, agent_result, :failed_attempt, model_id)
182
+ end
183
+
184
+ def snapshot_and_finalize(agent_class, response, model_used)
185
+ agent_result = Workflow::AgentResult.from_response(response, response&.content, model_used: model_used)
186
+ Thread.current[:smith_last_agent_result] = agent_result
187
+ emit_token_usage(agent_result)
188
+ compute_agent_cost(agent_result)
189
+ record_usage(agent_class, agent_result, :completed_attempt, agent_result.model_used)
190
+
191
+ agent_result.content = run_after_completion(agent_class, agent_result.content, @context)
192
+ raise_blank_output!(agent_class, agent_result)
193
+ agent_result
194
+ end
195
+
196
+ def raise_blank_output!(agent_class, agent_result)
197
+ return unless blank_agent_output?(agent_result.content)
198
+
199
+ raise Smith::BlankAgentOutputError.new(
200
+ agent_name: agent_class.register_as,
201
+ model_used: agent_result.model_used
202
+ )
203
+ end
204
+
205
+ def blank_agent_output?(content)
206
+ return true if content.nil?
207
+ return content.strip.empty? if content.is_a?(String)
208
+
209
+ false
210
+ end
211
+
212
+ def emit_token_usage(agent_result)
213
+ return unless agent_result.usage_known?
214
+
215
+ Smith::Trace.record(
216
+ type: :token_usage,
217
+ data: { input_tokens: agent_result.input_tokens, output_tokens: agent_result.output_tokens }
218
+ )
219
+ end
220
+
221
+ def compute_agent_cost(agent_result)
222
+ return unless agent_result.usage_known?
223
+
224
+ model = agent_result.model_used
225
+ agent_result.cost = Smith::Pricing.compute_cost(
226
+ model: model, input_tokens: agent_result.input_tokens, output_tokens: agent_result.output_tokens
227
+ )
228
+ end
229
+
230
+ # Single critical section: all three of `@total_tokens`,
231
+ # `@total_cost`, and `@usage_entries` update under one mutex
232
+ # acquisition. Replaces the prior `accumulate_usage` which took
233
+ # the mutex twice (once for tokens, once for cost) — under
234
+ # parallel fan-out two branches could interleave between those
235
+ # blocks, leaving totals momentarily inconsistent. Adding the
236
+ # entry append in a third pass would have widened the window;
237
+ # one pass closes it entirely.
238
+ #
239
+ # `@usage_mutex` is eagerly initialized in `Workflow#initialize`
240
+ # AND `Workflow#restore_state` (since `from_state` allocates
241
+ # without `initialize`), so it's always present here.
242
+ def record_usage(agent_class, agent_result, attempt_kind, model_id)
243
+ return unless agent_result.usage_known?
244
+
245
+ entry = Workflow::UsageEntry.new(
246
+ usage_id: SecureRandom.uuid,
247
+ agent_name: agent_class.register_as,
248
+ model: model_id,
249
+ input_tokens: agent_result.input_tokens,
250
+ output_tokens: agent_result.output_tokens,
251
+ cost: agent_result.cost,
252
+ attempt_kind: attempt_kind,
253
+ recorded_at: Time.now.utc.iso8601
254
+ )
255
+
256
+ @usage_mutex.synchronize do
257
+ @total_tokens = (@total_tokens || 0) + agent_result.input_tokens + agent_result.output_tokens
258
+ @total_cost = (@total_cost || 0.0) + (agent_result.cost || 0.0)
259
+ @usage_entries << entry
260
+ end
261
+ end
262
+ end
263
+ end
264
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-container"
4
+ require "monitor"
5
+
6
+ module Smith
7
+ class Agent
8
+ module Registry
9
+ extend Dry::Container::Mixin
10
+
11
+ def self.normalize_key(name)
12
+ name.to_s
13
+ end
14
+
15
+ def self.find(name)
16
+ registry_monitor.synchronize do
17
+ key = normalize_key(name)
18
+ key?(key) ? resolve(key) : nil
19
+ end
20
+ end
21
+
22
+ # Override Dry::Container::Mixin#register to route agent classes
23
+ # through ensure_registered while preserving full generic container
24
+ # semantics (block, options) for non-agent registrations.
25
+ def self.register(key, contents = nil, options = {}, &block)
26
+ if block_given? || !(contents.is_a?(Class) && contents <= Smith::Agent)
27
+ registry_monitor.synchronize { super(key, contents, options, &block) }
28
+ else
29
+ ensure_registered(key, contents)
30
+ end
31
+ end
32
+
33
+ def self.delete(name)
34
+ registry_monitor.synchronize do
35
+ _container.delete(normalize_key(name))
36
+ end
37
+ end
38
+
39
+ def self.clear!
40
+ registry_monitor.synchronize do
41
+ @_container&.clear
42
+ end
43
+ end
44
+
45
+ def self.ensure_registered(name, klass)
46
+ validate_agent_class!(klass)
47
+ key = normalize_key(name)
48
+
49
+ registry_monitor.synchronize do
50
+ existing = key?(key) ? resolve(key) : nil
51
+
52
+ if existing.nil?
53
+ register_unchecked!(key, klass)
54
+ elsif existing.equal?(klass)
55
+ # same object — no-op
56
+ elsif stale_reload_binding?(existing, klass)
57
+ # same class name, different object — Rails reload case
58
+ _container.delete(key)
59
+ register_unchecked!(key, klass)
60
+ else
61
+ raise Smith::AgentRegistryError,
62
+ "agent registry collision for key #{key.inspect}: " \
63
+ "already registered to #{binding_label(existing)}, " \
64
+ "cannot replace with #{binding_label(klass)}"
65
+ end
66
+
67
+ klass
68
+ end
69
+ end
70
+
71
+ def self.fetch!(name, workflow_class: nil, transition_name: nil, role: :agent)
72
+ registry_monitor.synchronize do
73
+ key = normalize_key(name)
74
+ return resolve(key) if key?(key)
75
+
76
+ details = []
77
+ details << "workflow #{workflow_class}" if workflow_class
78
+ details << "transition :#{transition_name}" if transition_name
79
+ suffix = details.empty? ? "" : " for #{details.join(', ')}"
80
+
81
+ raise Smith::WorkflowError, "unresolved #{role} :#{key}#{suffix}"
82
+ end
83
+ end
84
+
85
+ # Re-entrant lock (Monitor, not Mutex) so block-backed resolve
86
+ # inside find/fetch! can safely re-enter the registry without
87
+ # deadlocking on the same thread.
88
+ def self.registry_monitor
89
+ @_registry_monitor ||= Monitor.new
90
+ end
91
+
92
+ def self.validate_agent_class!(klass)
93
+ return if klass.is_a?(Class) && klass <= Smith::Agent
94
+
95
+ raise Smith::AgentRegistryError,
96
+ "expected a Smith::Agent subclass, got #{klass.inspect}"
97
+ end
98
+
99
+ # Safe label for collision error messages. Handles both classes
100
+ # (which respond to .name) and plain values (which do not).
101
+ def self.binding_label(value)
102
+ if value.respond_to?(:name) && value.name.is_a?(String) && !value.name.empty?
103
+ value.name
104
+ else
105
+ value.inspect
106
+ end
107
+ end
108
+ private_class_method :binding_label
109
+
110
+ # Private raw registration that bypasses ensure_registered.
111
+ # Used internally to avoid recursion/deadlock.
112
+ # Caller MUST already hold registry_monitor.
113
+ def self.register_unchecked!(key, klass)
114
+ config.registry.call(_container, key, klass, {})
115
+ end
116
+ private_class_method :register_unchecked!
117
+
118
+ def self.stale_reload_binding?(existing, klass)
119
+ existing_name = existing.respond_to?(:name) ? existing.name : nil
120
+ klass_name = klass.name
121
+ existing_name.is_a?(String) && !existing_name.empty? &&
122
+ klass_name.is_a?(String) && !klass_name.empty? &&
123
+ existing_name == klass_name
124
+ end
125
+ private_class_method :stale_reload_binding?
126
+ end
127
+ end
128
+ end