smith-agents 0.7.0 → 0.8.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 (110) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +226 -0
  3. data/README.md +71 -1
  4. data/docs/CONFIGURATION.md +22 -1
  5. data/docs/PERSISTENCE.md +27 -1
  6. data/docs/TOOLS_AND_GUARDRAILS.md +233 -2
  7. data/lib/smith/agent/chat_construction.rb +112 -0
  8. data/lib/smith/agent/completion.rb +46 -0
  9. data/lib/smith/agent/completion_usage_recording.rb +25 -0
  10. data/lib/smith/agent/dynamic_configuration.rb +69 -0
  11. data/lib/smith/agent/fallback_configuration.rb +26 -0
  12. data/lib/smith/agent/invocation_preparation.rb +104 -0
  13. data/lib/smith/agent/lifecycle.rb +7 -276
  14. data/lib/smith/agent/model_reference.rb +76 -0
  15. data/lib/smith/agent/provider_attempt.rb +28 -0
  16. data/lib/smith/agent/provider_candidate_sequence.rb +55 -0
  17. data/lib/smith/agent/provider_completion.rb +130 -0
  18. data/lib/smith/agent/provider_failure_handling.rb +93 -0
  19. data/lib/smith/agent/provider_usage.rb +28 -0
  20. data/lib/smith/agent/reserved_input_bridge.rb +40 -0
  21. data/lib/smith/agent/usage_entry_recording.rb +43 -0
  22. data/lib/smith/agent/usage_tracking.rb +113 -0
  23. data/lib/smith/agent.rb +29 -197
  24. data/lib/smith/context/observation_masking.rb +28 -4
  25. data/lib/smith/context/session.rb +7 -2
  26. data/lib/smith/diagnostic_text.rb +67 -0
  27. data/lib/smith/doctor/checks/configuration.rb +16 -3
  28. data/lib/smith/doctor/checks/models_registry.rb +75 -17
  29. data/lib/smith/errors.rb +34 -1
  30. data/lib/smith/models/ambiguous_profile_error.rb +9 -0
  31. data/lib/smith/models/collision_error.rb +9 -0
  32. data/lib/smith/models/inference.rb +14 -0
  33. data/lib/smith/models/normalizer.rb +11 -67
  34. data/lib/smith/models/provider_qualified_registry.rb +101 -0
  35. data/lib/smith/models/tool_routing.rb +140 -0
  36. data/lib/smith/models.rb +24 -78
  37. data/lib/smith/persisted_failure_invalid.rb +7 -0
  38. data/lib/smith/pricing.rb +138 -17
  39. data/lib/smith/pricing_configuration_error.rb +7 -0
  40. data/lib/smith/provider_permanent_failure.rb +16 -0
  41. data/lib/smith/tool/argument_container_reader.rb +97 -0
  42. data/lib/smith/tool/argument_scalar_snapshot.rb +99 -0
  43. data/lib/smith/tool/argument_snapshot.rb +19 -0
  44. data/lib/smith/tool/argument_snapshot_accounting.rb +69 -0
  45. data/lib/smith/tool/argument_snapshot_result.rb +15 -0
  46. data/lib/smith/tool/argument_snapshot_traversal.rb +119 -0
  47. data/lib/smith/tool/bounded_completion_context.rb +131 -0
  48. data/lib/smith/tool/bounded_completion_controls.rb +113 -0
  49. data/lib/smith/tool/bounded_completion_guard.rb +101 -0
  50. data/lib/smith/tool/bounded_completion_installation.rb +20 -0
  51. data/lib/smith/tool/bounded_completion_state.rb +57 -0
  52. data/lib/smith/tool/budget_enforcement.rb +15 -7
  53. data/lib/smith/tool/call_admission.rb +44 -0
  54. data/lib/smith/tool/call_allowance.rb +101 -28
  55. data/lib/smith/tool/call_allowance_counter.rb +43 -0
  56. data/lib/smith/tool/call_batch.rb +47 -0
  57. data/lib/smith/tool/call_budget.rb +51 -0
  58. data/lib/smith/tool/call_reservation.rb +42 -0
  59. data/lib/smith/tool/chat_execution_callbacks.rb +58 -0
  60. data/lib/smith/tool/chat_execution_context.rb +7 -88
  61. data/lib/smith/tool/execution_authority.rb +42 -0
  62. data/lib/smith/tool/execution_authorization.rb +17 -0
  63. data/lib/smith/tool/execution_batch.rb +100 -0
  64. data/lib/smith/tool/execution_batch_admission.rb +56 -0
  65. data/lib/smith/tool/execution_batch_builder.rb +122 -0
  66. data/lib/smith/tool/execution_batch_collection.rb +70 -0
  67. data/lib/smith/tool/execution_batch_invocations.rb +28 -0
  68. data/lib/smith/tool/execution_batch_lifecycle.rb +91 -0
  69. data/lib/smith/tool/execution_batch_registry.rb +99 -0
  70. data/lib/smith/tool/execution_batch_requests.rb +59 -0
  71. data/lib/smith/tool/execution_batch_source_call.rb +36 -0
  72. data/lib/smith/tool/execution_batch_source_metadata.rb +107 -0
  73. data/lib/smith/tool/execution_batch_sources.rb +32 -0
  74. data/lib/smith/tool/execution_batch_state.rb +110 -0
  75. data/lib/smith/tool/execution_dispatch.rb +78 -0
  76. data/lib/smith/tool/execution_failure_handling.rb +101 -0
  77. data/lib/smith/tool/execution_lifecycle.rb +28 -0
  78. data/lib/smith/tool/execution_tracker.rb +20 -0
  79. data/lib/smith/tool/fail_fast_completion.rb +24 -0
  80. data/lib/smith/tool/graceful_completion.rb +20 -0
  81. data/lib/smith/tool/invocation.rb +27 -0
  82. data/lib/smith/tool/invocation_request.rb +37 -0
  83. data/lib/smith/tool/invocation_sequence.rb +26 -0
  84. data/lib/smith/tool/legacy_call_allowance.rb +57 -0
  85. data/lib/smith/tool/scoped_context.rb +60 -8
  86. data/lib/smith/tool.rb +71 -12
  87. data/lib/smith/tool_execution_not_admitted.rb +7 -0
  88. data/lib/smith/tool_failure_notification_failed.rb +94 -0
  89. data/lib/smith/version.rb +2 -2
  90. data/lib/smith/workflow/agent_result.rb +4 -3
  91. data/lib/smith/workflow/artifact_integration.rb +2 -1
  92. data/lib/smith/workflow/composite/effects.rb +3 -1
  93. data/lib/smith/workflow/deadline_enforcement.rb +20 -2
  94. data/lib/smith/workflow/execution.rb +2 -9
  95. data/lib/smith/workflow/failure_detail_snapshot.rb +33 -0
  96. data/lib/smith/workflow/failure_reconstructor.rb +79 -0
  97. data/lib/smith/workflow/failure_record.rb +139 -0
  98. data/lib/smith/workflow/failure_record_restore.rb +145 -0
  99. data/lib/smith/workflow/failure_record_text.rb +83 -0
  100. data/lib/smith/workflow/failure_record_validator.rb +113 -0
  101. data/lib/smith/workflow/guarded_step_execution.rb +39 -0
  102. data/lib/smith/workflow/parallel.rb +14 -3
  103. data/lib/smith/workflow/persistence.rb +22 -19
  104. data/lib/smith/workflow/retry_execution.rb +5 -1
  105. data/lib/smith/workflow/thread_context_snapshot.rb +5 -2
  106. data/lib/smith/workflow/transition.rb +9 -4
  107. data/lib/smith/workflow/usage_entry.rb +12 -2
  108. data/lib/smith/workflow.rb +17 -130
  109. data/lib/smith.rb +3 -0
  110. metadata +78 -11
@@ -33,6 +33,91 @@ class RefundCustomer < Smith::Tool
33
33
  end
34
34
  ```
35
35
 
36
+ `params` and Ruby keyword declarations describe the model-facing tool schema.
37
+ Tool arguments remain untrusted. A host adapter must validate its own input
38
+ contract inside `perform` before external work and return a bounded failure
39
+ payload for expected validation or provider failures. It should raise for
40
+ configuration, programming, authorization-integrity, or durability failures.
41
+ Smith applies policy and budgeting around that host boundary; it does not
42
+ interpret a host's input schema or failure envelope.
43
+
44
+ ### Bounded Completion
45
+
46
+ The default exhaustion policy remains fail-fast:
47
+
48
+ ```ruby
49
+ class SearchAgent < Smith::Agent
50
+ tools WebSearch
51
+ budget tool_calls: 3
52
+ tool_budget_exhaustion :raise
53
+ end
54
+ ```
55
+
56
+ Read-only agents that can finish from partial evidence may opt into graceful
57
+ completion:
58
+
59
+ ```ruby
60
+ class SearchAgent < Smith::Agent
61
+ tools WebSearch
62
+ budget tool_calls: 3
63
+ tool_budget_exhaustion :complete
64
+ end
65
+ ```
66
+
67
+ Hosts with signed per-tool policy can replace the aggregate integer with an
68
+ exact budget:
69
+
70
+ ```ruby
71
+ budget = Smith::Tool::CallBudget.new(
72
+ total: 4,
73
+ tool_limits: {
74
+ "weather_forecast" => 1,
75
+ "web_search" => 3
76
+ }
77
+ )
78
+
79
+ Smith::Tool.with_call_budget(budget, on_exhaustion: :complete) do
80
+ workflow.advance
81
+ end
82
+ ```
83
+
84
+ An agent may use the same value in `budget tool_calls: budget`. The enclosing
85
+ allowance is a shared transition cap. Same-agent parallel branches,
86
+ heterogeneous fan-out branches, and repeated optimizer participants narrow that
87
+ shared cap to their own agent budget; they do not receive independent copies.
88
+ Batch admission checks the aggregate and per-tool counters atomically before
89
+ any tool runs.
90
+
91
+ `:complete` requires a finite budget and RubyLLM 1.16.0. While more than one
92
+ call remains, Smith permits the provider to return multiple calls in one
93
+ response; the final remaining call uses the single-call provider hint. Smith
94
+ still executes an admitted batch sequentially and advances the provider loop
95
+ iteratively without recursive Ruby stack growth. The provider control is
96
+ advisory: every returned batch is checked atomically. A batch larger than the
97
+ remaining allowance executes no calls, receives one tool result for every
98
+ requested call id, and is followed by one completion with the tools removed.
99
+ Exact exhaustion follows the same tool-disabled completion path. The original
100
+ tools, call preferences, and concurrency setting are restored in `ensure`.
101
+
102
+ The allowance counts model-requested calls, including unavailable tool names.
103
+ An exact admitted Smith tool receives a one-use admission so it is not charged
104
+ twice; a nested Smith tool must consume its own allowance. Batch admission
105
+ reserves the effective workflow tool budget before any tool runs, then
106
+ reconciles calls rejected before `perform`. Calls on the same bounded chat
107
+ cannot complete concurrently or reenter through callbacks. The opt-in bounded
108
+ policy accepts only `Smith::Tool` bindings so Smith can enforce these guarantees;
109
+ ordinary RubyLLM tools retain their existing behavior when the policy is not
110
+ enabled. Raw provider params may not override `tools`, tool choice, or parallel
111
+ tool-call controls while bounded completion is active. Smith supplies the
112
+ provider call-cardinality hint only when the RubyLLM model registry advertises
113
+ `parallel_tool_calls`, or when the selected Responses endpoint supports that
114
+ control. Otherwise the hint is omitted and Smith's atomic local batch admission
115
+ remains authoritative.
116
+
117
+ This is not a durable model-loop checkpoint. A host still needs an operation
118
+ receipt and reconciliation boundary before replaying side effects or recovering
119
+ mid-loop from process loss.
120
+
36
121
  ### Tool Compatibility (provider-aware tool selection)
37
122
 
38
123
  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).
@@ -48,7 +133,10 @@ class WebSearch < Smith::Tool
48
133
  end
49
134
  ```
50
135
 
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.
136
+ When `Smith.config.openai_api_mode = :auto` (the default) AND the tool requires
137
+ `/v1/responses`, the normalizer sets `@params[:openai_api_mode] = :responses` so
138
+ the routing prepend can dispatch via the compatible endpoint. This applies with
139
+ or without model thinking. When `:off`, the tool is dropped gracefully.
52
140
 
53
141
  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
142
 
@@ -136,11 +224,154 @@ requiring Smith does not alter raw RubyLLM chats. Arbitrary chats, threads, or
136
224
  fibers created by host code are outside this boundary and must install their own
137
225
  scope. Hosts remain responsible for using an immutable value and installing a
138
226
  fresh context after durable resume; context-dependent adapters must fail closed
139
- when no context is installed. Smith supports RubyLLM `>= 1.15, < 1.17` for this
227
+ when no context is installed. Smith supports RubyLLM `1.16.0` for this
140
228
  integration and fails closed if the required per-chat tool execution hook is
141
229
  unavailable. RubyLLM fiber concurrency still requires its optional `async`
142
230
  dependency; Smith does not make that scheduler a runtime dependency.
143
231
 
232
+ Smith applies the same model normalization and tool-compatibility routing to
233
+ persisted chats returned by `create`, `create!`, and `find` as it does to direct
234
+ `chat` construction. The configured transport provider is authoritative even
235
+ when the model identifier is commonly associated with another provider;
236
+ provider-specific endpoint assumptions are cleared when the transport changes.
237
+ Reserved Smith inputs are available before persisted-chat dynamic
238
+ configuration runs. When OpenAI endpoint routing is automatic, Smith compares
239
+ the compatible tool counts in one pass and selects Responses only when it
240
+ preserves a strictly larger subset; a tie keeps the current endpoint. A
241
+ specific forced tool takes precedence when only one enabled endpoint can carry
242
+ it. If normalization removes a specifically forced tool, Smith clears that
243
+ stale choice and attributes the reset only to that selected tool. An
244
+ endpoint-constrained tool routed to OpenAI Responses inherits the current
245
+ non-streaming Responses contract; callers requesting streaming receive the
246
+ documented explicit unsupported error rather than silent Chat Completions
247
+ fallback.
248
+
249
+ Bounded completion ownership and tool-call reservations mask asynchronous
250
+ interrupts while installing and restoring process-local state. User work runs
251
+ with normal interrupt delivery, and the protected cleanup finishes before an
252
+ interrupt can escape the boundary.
253
+
254
+ During an executing Smith tool, hosts can also read immutable normalized call
255
+ metadata:
256
+
257
+ ```ruby
258
+ invocation = Smith::Tool.current_invocation
259
+ invocation.tool_call_id
260
+ invocation.tool_name
261
+ invocation.ordinal
262
+ invocation.batch_ordinal
263
+ invocation.batch_size
264
+ ```
265
+
266
+ `tool_call_id` is RubyLLM's normalized call identifier. Some adapters preserve
267
+ an identifier supplied by the model provider and others synthesize one while
268
+ parsing, so it is correlation metadata, not a provider idempotency key or
269
+ authoritative replay proof. `ordinal` is allocated from one thread-safe sequence
270
+ shared by every Smith-managed chat, branch, thread, and fiber inside the current
271
+ host invocation scope.
272
+
273
+ Hosts that reconstruct an execution may supply an explicit next ordinal:
274
+
275
+ ```ruby
276
+ sequence = Smith::Tool::InvocationSequence.new(next_ordinal: 12)
277
+ Smith::Tool.with_invocation_context(context, invocation_sequence: sequence) do
278
+ workflow.advance
279
+ end
280
+ ```
281
+
282
+ Smith does not persist that sequence. A durable host must derive the resume
283
+ ordinal from its own authoritative ledger and must not resume a model/tool loop
284
+ unless it can also reconstruct the exact provider transcript and operation
285
+ outcomes. Without an invocation context Smith uses a chat-local sequence for
286
+ ordinary non-hosted tool execution.
287
+
288
+ Durable hosts may atomically admit the complete Smith-managed subset of each
289
+ provider batch before Smith dispatches any Smith tool:
290
+
291
+ ```ruby
292
+ Smith::Tool.with_invocation_context(
293
+ context,
294
+ invocation_sequence: sequence,
295
+ batch_admitter: ->(requests:) { receipts.admit_batch!(requests) },
296
+ failure_handler: ->(request:, error:) { receipts.fail_dispatch!(request, error) }
297
+ ) do
298
+ workflow.advance
299
+ end
300
+ ```
301
+
302
+ Smith snapshots provider-batch membership once using native `Hash` operations
303
+ after admitting its native cardinality, then verifies the owned snapshot retained
304
+ that cardinality and validates provider-call identity. The complete provider
305
+ batch is limited to 100 calls and 1 MiB of normalized UTF-8 call metadata before
306
+ host admission; aggregate capture stops as soon as the metadata limit is crossed,
307
+ before reading that crossing call's arguments. Every
308
+ model-requested call, including unavailable and plain RubyLLM names, is reserved
309
+ against the active Smith budget. For registered
310
+ `Smith::Tool` targets Smith owns a bounded, acyclic, JSON-compatible argument
311
+ snapshot and rejects duplicate string-equivalent object keys. It materializes
312
+ one immutable dispatch collection from those snapshots before host admission.
313
+ The batch callback receives the complete ordered frozen array of immutable
314
+ `InvocationRequest` values for the Smith targets. Returning normally asserts
315
+ that the host admitted that complete subset durably. Raising asserts that none
316
+ of that subset was admitted: Smith dispatches no Smith tool and does not invoke
317
+ the per-call failure handler. A host must therefore make callback return/raise
318
+ agree with its own atomic transaction.
319
+
320
+ Passing the provider collection and tool argument graph into this boundary
321
+ transfers temporary ownership to Smith for the duration of capture. The provider
322
+ and host must not mutate those containers concurrently. Smith admits native
323
+ cardinality before copying and rejects a copy whose cardinality changed, but it
324
+ deliberately does not freeze or otherwise mutate caller-owned containers.
325
+ Sequential mutation after capture cannot alter the owned batch.
326
+
327
+ Smith masks asynchronous interruption while the host admission callback runs and
328
+ while its successful return is recorded in the local batch state; this prevents
329
+ an interrupt from separating a committed host admission from Smith's dispatch
330
+ decision. The callback must therefore be bounded and timeout-controlled. It
331
+ should perform only the host's local atomic admission work, not provider I/O or
332
+ an unbounded remote request.
333
+
334
+ Configuring `batch_admitter` requires `failure_handler`. Smith rejects an
335
+ admission callback without the corresponding terminal-failure callback before
336
+ execution begins, so a host cannot opt into durable admission without also
337
+ providing a way to terminate every admitted receipt.
338
+
339
+ Unavailable calls and tools implemented directly as `RubyLLM::Tool` do not
340
+ produce `InvocationRequest` values and are not covered by the host callback.
341
+ They still consume the bounded allowance. A mixed batch can therefore be
342
+ host-atomic only for its Smith-managed subset; hosts that require a durable
343
+ receipt for every executable tool must bind only `Smith::Tool` adapters.
344
+
345
+ After a callback returns, RubyLLM executes Smith's immutable admitted
346
+ collection, not the caller-owned collection. Adding, removing, renaming, or
347
+ mutating source calls after the snapshot therefore cannot alter the admitted
348
+ execution. Smith still verifies that the exact registered tool object matches
349
+ the admitted target. Each admitted call acquires one atomic dispatch claim;
350
+ callback re-entry for the same admission is rejected before `perform` and cannot
351
+ repeat its effect. The managed dispatch also owns a one-use execution authority.
352
+ Calling a Smith tool directly from a provider callback or from another managed
353
+ Smith tool fails closed; nested execution requires a future separately admitted
354
+ invocation primitive. Tool replacement, a RubyLLM argument rejection, a callback
355
+ failure before `Smith::Tool#perform`, or a Smith deadline that expires before
356
+ `perform` raises `Smith::ToolDispatchRejected`. Smith attempts to notify every
357
+ unsettled admitted sibling in one bounded pass even when one failure handler
358
+ call raises. The callback must be idempotent because a failed notification
359
+ remains unsettled for conservative host recovery. Errors from a failed terminal
360
+ callback surface as `Smith::ToolFailureNotificationFailed` and are never
361
+ transition-retryable. The error retains both the dispatch failure and the host
362
+ callback failure.
363
+
364
+ For an admitted provider-batch call, pre-execution hooks and tool guardrails
365
+ receive an inspection-only argument Hash. The full admitted argument graph is
366
+ Smith-owned and frozen, so policy inspection cannot rewrite the values that
367
+ reach `perform`. Errors raised from inside `perform`, including
368
+ `Smith::DeadlineExceeded`, do not prove that external work was absent and retain
369
+ their original type. Process-fatal exceptions are never converted into receipt
370
+ outcomes or terminal callbacks: an admitted receipt remains unresolved and the
371
+ durable host must reconcile it before any resume decision. Smith never
372
+ automatically replays that uncertain work. Failure-handler errors propagate
373
+ because Smith cannot claim the host recorded a terminal outcome when it did not.
374
+
144
375
  You can still use RubyLLM agent tool wiring on your agents:
145
376
 
146
377
  ```ruby
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Agent
5
+ module ChatConstruction
6
+ def chat(**kwargs)
7
+ kwargs, profile = prepare_input_kwargs(kwargs)
8
+
9
+ llm_chat = install_tool_execution_context(super)
10
+ normalize_chat(llm_chat, fallback_profile: profile)
11
+ llm_chat
12
+ end
13
+
14
+ def create(**kwargs)
15
+ kwargs, = prepare_input_kwargs(kwargs)
16
+ prepare_persisted_chat(super, kwargs:)
17
+ end
18
+
19
+ def create!(**kwargs)
20
+ kwargs, = prepare_input_kwargs(kwargs)
21
+ prepare_persisted_chat(super, kwargs:)
22
+ end
23
+
24
+ def find(id, **kwargs)
25
+ kwargs, = prepare_input_kwargs(kwargs)
26
+ prepare_persisted_chat(super, kwargs:)
27
+ end
28
+
29
+ private
30
+
31
+ def install_tool_execution_context(chat_object)
32
+ return unless chat_object
33
+
34
+ llm_chat = chat_object.respond_to?(:to_llm) ? chat_object.to_llm : chat_object
35
+ Tool::ChatExecutionContext.install(llm_chat)
36
+ chat_object
37
+ end
38
+
39
+ def resolve_profile(model_id, provider: nil)
40
+ return unless model_id && defined?(Smith::Models)
41
+
42
+ Smith::Models.find_or_infer(model_id, provider: provider)
43
+ end
44
+
45
+ def prepare_persisted_chat(chat_object, kwargs:)
46
+ return unless chat_object
47
+
48
+ install_tool_execution_context(chat_object)
49
+ llm_chat = chat_object.respond_to?(:to_llm) ? chat_object.to_llm : chat_object
50
+ profile = resolve_profile(
51
+ persisted_model_id(chat_object, kwargs),
52
+ provider: persisted_provider(chat_object, kwargs)
53
+ )
54
+ normalize_chat(llm_chat, fallback_profile: profile)
55
+ chat_object
56
+ end
57
+
58
+ def normalize_chat(llm_chat, fallback_profile:)
59
+ chat_model = llm_chat.model if llm_chat.respond_to?(:model)
60
+ profile = resolve_profile(
61
+ chat_model&.id || fallback_profile&.model_id,
62
+ provider: actual_provider(llm_chat) || fallback_profile&.provider
63
+ )
64
+ Smith::Models::Normalizer.apply!(llm_chat, profile: profile) if profile
65
+ end
66
+
67
+ def persisted_model_id(chat_object, kwargs)
68
+ kwargs[:model] || (chat_object.model_id if chat_object.respond_to?(:model_id)) || chat_kwargs[:model]
69
+ end
70
+
71
+ def persisted_provider(chat_object, kwargs)
72
+ kwargs[:provider] || (chat_object.provider if chat_object.respond_to?(:provider)) || configured_provider(kwargs)
73
+ end
74
+
75
+ def configured_provider(kwargs)
76
+ return kwargs[:provider] if kwargs.key?(:provider)
77
+ return if kwargs.key?(:model) && kwargs[:model].to_s != chat_kwargs[:model].to_s
78
+
79
+ chat_kwargs[:provider]
80
+ end
81
+
82
+ def actual_provider(llm_chat)
83
+ provider = llm_chat.instance_variable_get(:@provider)
84
+ provider.slug if provider.respond_to?(:slug)
85
+ end
86
+
87
+ def prepare_input_kwargs(kwargs)
88
+ model_id = kwargs[:model] || chat_kwargs[:model]
89
+ provider = configured_provider(kwargs)
90
+ profile = resolve_profile(model_id, provider:)
91
+ prepared = nil_fill_declared_inputs(inject_reserved_inputs(kwargs, profile, provider:))
92
+ [prepared, profile]
93
+ end
94
+
95
+ def inject_reserved_inputs(kwargs, profile, provider:)
96
+ return kwargs unless profile
97
+
98
+ {
99
+ model_id: profile.model_id,
100
+ provider:,
101
+ endpoint_mode: profile.endpoint_mode
102
+ }.merge(kwargs)
103
+ end
104
+
105
+ def nil_fill_declared_inputs(kwargs)
106
+ inputs.each_with_object(kwargs.dup) do |name, result|
107
+ result[name] = nil unless result.key?(name)
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-struct"
4
+ require_relative "../types"
5
+ require_relative "provider_usage"
6
+
7
+ module Smith
8
+ class Agent
9
+ class Completion < Dry::Struct
10
+ attribute :response, Types::Any
11
+ attribute :provider_usages, Types::Array.of(Types.Instance(ProviderUsage))
12
+ attribute :usage_complete, Types::Bool
13
+
14
+ def self.from_messages(response:, messages:)
15
+ assistant_messages = Array(messages).select do |message|
16
+ message.respond_to?(:role) && message.role.to_s == "assistant"
17
+ end
18
+ usage_source = assistant_messages.empty? ? [response] : assistant_messages
19
+
20
+ provider_usages = build_provider_usages(usage_source)
21
+ new(
22
+ response: response,
23
+ provider_usages:,
24
+ usage_complete: provider_usages.length == usage_source.length
25
+ )
26
+ end
27
+
28
+ def content
29
+ response.content if response.respond_to?(:content)
30
+ end
31
+
32
+ def input_tokens
33
+ provider_usages.sum(&:input_tokens) if usage_complete
34
+ end
35
+
36
+ def output_tokens
37
+ provider_usages.sum(&:output_tokens) if usage_complete
38
+ end
39
+
40
+ def self.build_provider_usages(messages)
41
+ messages.filter_map { |message| ProviderUsage.from_message(message) }.freeze
42
+ end
43
+ private_class_method :build_provider_usages
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Agent
5
+ module CompletionUsageRecording
6
+ private
7
+
8
+ def record_completion_usage(agent_class, completion, attempt_kind, model_reference)
9
+ model_reference = coerce_model_reference(model_reference)
10
+ completion.provider_usages.each do |usage|
11
+ result = Workflow::AgentResult.new(
12
+ content: nil,
13
+ input_tokens: usage.input_tokens,
14
+ output_tokens: usage.output_tokens,
15
+ cost: nil,
16
+ model_used: model_reference.model_id,
17
+ provider_used: model_reference.provider
18
+ )
19
+ compute_agent_cost(result)
20
+ record_usage(agent_class, result, attempt_kind, model_reference)
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Agent
5
+ module DynamicConfiguration
6
+ attr_reader :model_block
7
+
8
+ def model(model_id = nil, **options, &block)
9
+ return configure_dynamic_model(model_id, options, block) if block
10
+
11
+ @model_block = nil
12
+ super
13
+ end
14
+
15
+ def model_configured?
16
+ !chat_kwargs[:model].nil? || !@model_block.nil?
17
+ end
18
+
19
+ def tools(*tools, &block)
20
+ return super unless block
21
+
22
+ super(&wrap_runtime_block(block))
23
+ end
24
+
25
+ def instructions(text = nil, **prompt_locals, &block)
26
+ return super unless block
27
+
28
+ super(text, **prompt_locals, &wrap_runtime_block(block))
29
+ end
30
+
31
+ def params(**params_kwargs, &block)
32
+ return super unless block
33
+
34
+ super(&wrap_runtime_block(block))
35
+ end
36
+
37
+ def headers(**headers_kwargs, &block)
38
+ return super unless block
39
+
40
+ super(&wrap_runtime_block(block))
41
+ end
42
+
43
+ def schema(value = nil, &block)
44
+ return super unless block
45
+
46
+ super(&wrap_runtime_block(block))
47
+ end
48
+
49
+ private
50
+
51
+ def configure_dynamic_model(model_id, options, block)
52
+ raise ArgumentError, "model can take a string id OR a block, not both" if model_id || options.any?
53
+
54
+ @model_block = block
55
+ @chat_kwargs ||= {}
56
+ @chat_kwargs.delete(:model)
57
+ end
58
+
59
+ def wrap_runtime_block(user_block)
60
+ return user_block if user_block.arity.zero?
61
+
62
+ proc do |*|
63
+ runtime = self
64
+ runtime.instance_exec(runtime, &user_block)
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Agent
5
+ module FallbackConfiguration
6
+ def fallback_models(*models)
7
+ return @fallback_models_list if models.empty?
8
+
9
+ entries = models.flatten.compact.map { qualified_fallback_reference(_1) }
10
+ @fallback_models_list = entries.uniq(&:key).freeze
11
+ end
12
+
13
+ private
14
+
15
+ def qualified_fallback_reference(model)
16
+ reference = ModelReference.coerce(model)
17
+ return reference if reference.provider
18
+
19
+ raise Smith::WorkflowError,
20
+ "fallback model #{reference.model_id.inspect} must include an explicit provider"
21
+ rescue ArgumentError, Dry::Struct::Error => e
22
+ raise Smith::WorkflowError, "invalid fallback model: #{e.message}"
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Agent
5
+ module InvocationPreparation
6
+ WORKFLOW_CONTINUATION_MESSAGE =
7
+ "Use the preceding assistant result as input and perform your assigned workflow step."
8
+
9
+ private_constant :WORKFLOW_CONTINUATION_MESSAGE
10
+
11
+ private
12
+
13
+ def bridge_workflow_inputs(agent_class)
14
+ return {} unless @context.is_a?(Hash)
15
+
16
+ declared = agent_class.inputs || []
17
+ user_declared = declared - Smith::Agent::RESERVED_INPUT_NAMES
18
+ user_declared.to_h do |name|
19
+ [name, @context[name]]
20
+ end
21
+ end
22
+
23
+ def add_prepared_input(chat, prepared_input)
24
+ return unless prepared_input
25
+
26
+ prepared_input = provider_safe_prepared_input(prepared_input)
27
+ system_messages, other_messages = prepared_input.partition do |message|
28
+ message_role(message) == :system
29
+ end
30
+
31
+ merge_system_messages!(chat, system_messages) if system_messages.any?
32
+ other_messages.each { |message| add_message(chat, message) }
33
+ end
34
+
35
+ def provider_safe_prepared_input(prepared_input)
36
+ messages = prepared_input.to_a
37
+ return messages unless workflow_handoff?(messages)
38
+
39
+ messages + [{ role: :user, content: WORKFLOW_CONTINUATION_MESSAGE }]
40
+ end
41
+
42
+ def workflow_handoff?(messages)
43
+ message = messages.reverse_each.find { |candidate| message_role(candidate) != :system }
44
+ return false unless message
45
+ return false unless message_role(message) == :assistant
46
+ return false unless defined?(@last_output) && !@last_output.nil?
47
+
48
+ message_content(message) == @last_output
49
+ end
50
+
51
+ def merge_system_messages!(chat, prepared_system_messages)
52
+ return append_system_messages(chat, prepared_system_messages) unless chat.respond_to?(:messages)
53
+
54
+ combined_contents = existing_system_contents(chat) + prepared_system_contents(prepared_system_messages)
55
+ return if combined_contents.empty?
56
+ return append_system_messages(chat, prepared_system_messages) unless combined_contents.all?(String)
57
+
58
+ if chat.respond_to?(:with_instructions)
59
+ chat.with_instructions(combined_contents.join("\n\n"))
60
+ else
61
+ append_system_messages(chat, prepared_system_messages)
62
+ end
63
+ end
64
+
65
+ def append_system_messages(chat, messages)
66
+ messages.each { |message| add_message(chat, message) }
67
+ end
68
+
69
+ def existing_system_contents(chat)
70
+ chat.messages.filter_map do |message|
71
+ message.content if message_role(message) == :system
72
+ end
73
+ end
74
+
75
+ def prepared_system_contents(messages)
76
+ messages.filter_map { |message| message_content(message) }
77
+ end
78
+
79
+ def add_message(chat, message)
80
+ attributes = if message.is_a?(Hash)
81
+ message.transform_keys { |key| key.respond_to?(:to_sym) ? key.to_sym : key }
82
+ else
83
+ message
84
+ end
85
+ chat.add_message(attributes)
86
+ end
87
+
88
+ def message_role(message)
89
+ message_attribute(message, :role)&.to_sym
90
+ end
91
+
92
+ def message_content(message)
93
+ message_attribute(message, :content)
94
+ end
95
+
96
+ def message_attribute(message, name)
97
+ return message.public_send(name) if message.respond_to?(name)
98
+ return message[name] if message.respond_to?(:key?) && message.key?(name)
99
+
100
+ message[name.to_s]
101
+ end
102
+ end
103
+ end
104
+ end