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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +139 -0
- data/CODE_OF_CONDUCT.md +128 -0
- data/LICENSE +21 -0
- data/README.md +226 -0
- data/Rakefile +14 -0
- data/UPSTREAM_PROPOSAL.md +141 -0
- data/docs/CONFIGURATION.md +123 -0
- data/docs/PATTERNS.md +492 -0
- data/docs/PERSISTENCE.md +169 -0
- data/docs/TOOLS_AND_GUARDRAILS.md +140 -0
- data/docs/workflow_claim.md +58 -0
- data/exe/smith +7 -0
- data/lib/generators/smith/install/install_generator.rb +22 -0
- data/lib/generators/smith/install/templates/smith.rb.tt +44 -0
- data/lib/smith/agent/lifecycle.rb +264 -0
- data/lib/smith/agent/registry.rb +128 -0
- data/lib/smith/agent.rb +259 -0
- data/lib/smith/artifacts/file.rb +59 -0
- data/lib/smith/artifacts/memory.rb +75 -0
- data/lib/smith/artifacts/scoped_store.rb +29 -0
- data/lib/smith/artifacts.rb +5 -0
- data/lib/smith/budget/ledger.rb +42 -0
- data/lib/smith/budget.rb +5 -0
- data/lib/smith/cli.rb +82 -0
- data/lib/smith/context/observation_masking.rb +19 -0
- data/lib/smith/context/session.rb +42 -0
- data/lib/smith/context/state_injection.rb +24 -0
- data/lib/smith/context.rb +61 -0
- data/lib/smith/doctor/check.rb +12 -0
- data/lib/smith/doctor/checks/baseline.rb +84 -0
- data/lib/smith/doctor/checks/configuration.rb +56 -0
- data/lib/smith/doctor/checks/durability.rb +103 -0
- data/lib/smith/doctor/checks/live.rb +55 -0
- data/lib/smith/doctor/checks/models_registry.rb +66 -0
- data/lib/smith/doctor/checks/openai_api_mode.rb +51 -0
- data/lib/smith/doctor/checks/persistence.rb +99 -0
- data/lib/smith/doctor/checks/persistence_capabilities.rb +60 -0
- data/lib/smith/doctor/checks/persistence_registry.rb +82 -0
- data/lib/smith/doctor/checks/rails.rb +39 -0
- data/lib/smith/doctor/checks/serialization.rb +78 -0
- data/lib/smith/doctor/installer.rb +103 -0
- data/lib/smith/doctor/printer.rb +62 -0
- data/lib/smith/doctor/report.rb +39 -0
- data/lib/smith/doctor.rb +53 -0
- data/lib/smith/errors.rb +191 -0
- data/lib/smith/event.rb +11 -0
- data/lib/smith/events/.keep +0 -0
- data/lib/smith/events/bus.rb +60 -0
- data/lib/smith/events/step_completed.rb +11 -0
- data/lib/smith/events/subscription.rb +24 -0
- data/lib/smith/events.rb +5 -0
- data/lib/smith/guardrails/runner.rb +44 -0
- data/lib/smith/guardrails/url_verifier.rb +7 -0
- data/lib/smith/guardrails.rb +35 -0
- data/lib/smith/models/inference.rb +199 -0
- data/lib/smith/models/normalizer.rb +186 -0
- data/lib/smith/models/profile.rb +39 -0
- data/lib/smith/models.rb +132 -0
- data/lib/smith/persistence_adapters/active_record_store.rb +99 -0
- data/lib/smith/persistence_adapters/cache_store.rb +79 -0
- data/lib/smith/persistence_adapters/memory.rb +105 -0
- data/lib/smith/persistence_adapters/rails_cache.rb +20 -0
- data/lib/smith/persistence_adapters/redis_store.rb +136 -0
- data/lib/smith/persistence_adapters/retry.rb +42 -0
- data/lib/smith/persistence_adapters.rb +112 -0
- data/lib/smith/pricing.rb +65 -0
- data/lib/smith/providers/openai/responses.rb +315 -0
- data/lib/smith/providers/openai/routing.rb +67 -0
- data/lib/smith/providers/openai/tools_extensions.rb +106 -0
- data/lib/smith/railtie.rb +9 -0
- data/lib/smith/tasks/doctor.rake +38 -0
- data/lib/smith/tool/budget_enforcement.rb +33 -0
- data/lib/smith/tool/capability_builder.rb +18 -0
- data/lib/smith/tool/capture.rb +22 -0
- data/lib/smith/tool/compatibility.rb +72 -0
- data/lib/smith/tool/policy.rb +40 -0
- data/lib/smith/tool.rb +171 -0
- data/lib/smith/tools/think.rb +25 -0
- data/lib/smith/tools/url_fetcher.rb +16 -0
- data/lib/smith/tools/web_search.rb +17 -0
- data/lib/smith/tools.rb +5 -0
- data/lib/smith/trace/logger.rb +46 -0
- data/lib/smith/trace/memory.rb +53 -0
- data/lib/smith/trace/open_telemetry.rb +57 -0
- data/lib/smith/trace.rb +89 -0
- data/lib/smith/types.rb +16 -0
- data/lib/smith/version.rb +5 -0
- data/lib/smith/workflow/artifact_integration.rb +41 -0
- data/lib/smith/workflow/budget_integration.rb +105 -0
- data/lib/smith/workflow/claim.rb +118 -0
- data/lib/smith/workflow/data_volume_policy.rb +36 -0
- data/lib/smith/workflow/deadline_enforcement.rb +100 -0
- data/lib/smith/workflow/deterministic_execution.rb +53 -0
- data/lib/smith/workflow/deterministic_step.rb +57 -0
- data/lib/smith/workflow/dsl.rb +223 -0
- data/lib/smith/workflow/durability.rb +369 -0
- data/lib/smith/workflow/evaluator_optimizer.rb +220 -0
- data/lib/smith/workflow/event_integration.rb +24 -0
- data/lib/smith/workflow/execution.rb +127 -0
- data/lib/smith/workflow/execution_frame.rb +166 -0
- data/lib/smith/workflow/guardrail_integration.rb +40 -0
- data/lib/smith/workflow/nested_execution.rb +69 -0
- data/lib/smith/workflow/orchestrator_worker.rb +145 -0
- data/lib/smith/workflow/parallel.rb +50 -0
- data/lib/smith/workflow/parallel_execution.rb +75 -0
- data/lib/smith/workflow/persistence.rb +358 -0
- data/lib/smith/workflow/pipeline.rb +117 -0
- data/lib/smith/workflow/router.rb +53 -0
- data/lib/smith/workflow/transition.rb +208 -0
- data/lib/smith/workflow.rb +555 -0
- data/lib/smith.rb +254 -0
- data/script/profile_tool_results.rb +94 -0
- data/sig/smith.rbs +4 -0
- 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,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
|