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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +226 -0
- data/README.md +71 -1
- data/docs/CONFIGURATION.md +22 -1
- data/docs/PERSISTENCE.md +27 -1
- data/docs/TOOLS_AND_GUARDRAILS.md +233 -2
- data/lib/smith/agent/chat_construction.rb +112 -0
- data/lib/smith/agent/completion.rb +46 -0
- data/lib/smith/agent/completion_usage_recording.rb +25 -0
- data/lib/smith/agent/dynamic_configuration.rb +69 -0
- data/lib/smith/agent/fallback_configuration.rb +26 -0
- data/lib/smith/agent/invocation_preparation.rb +104 -0
- data/lib/smith/agent/lifecycle.rb +7 -276
- data/lib/smith/agent/model_reference.rb +76 -0
- data/lib/smith/agent/provider_attempt.rb +28 -0
- data/lib/smith/agent/provider_candidate_sequence.rb +55 -0
- data/lib/smith/agent/provider_completion.rb +130 -0
- data/lib/smith/agent/provider_failure_handling.rb +93 -0
- data/lib/smith/agent/provider_usage.rb +28 -0
- data/lib/smith/agent/reserved_input_bridge.rb +40 -0
- data/lib/smith/agent/usage_entry_recording.rb +43 -0
- data/lib/smith/agent/usage_tracking.rb +113 -0
- data/lib/smith/agent.rb +29 -197
- data/lib/smith/context/observation_masking.rb +28 -4
- data/lib/smith/context/session.rb +7 -2
- data/lib/smith/diagnostic_text.rb +67 -0
- data/lib/smith/doctor/checks/configuration.rb +16 -3
- data/lib/smith/doctor/checks/models_registry.rb +75 -17
- data/lib/smith/errors.rb +34 -1
- data/lib/smith/models/ambiguous_profile_error.rb +9 -0
- data/lib/smith/models/collision_error.rb +9 -0
- data/lib/smith/models/inference.rb +14 -0
- data/lib/smith/models/normalizer.rb +11 -67
- data/lib/smith/models/provider_qualified_registry.rb +101 -0
- data/lib/smith/models/tool_routing.rb +140 -0
- data/lib/smith/models.rb +24 -78
- data/lib/smith/persisted_failure_invalid.rb +7 -0
- data/lib/smith/pricing.rb +138 -17
- data/lib/smith/pricing_configuration_error.rb +7 -0
- data/lib/smith/provider_permanent_failure.rb +16 -0
- data/lib/smith/tool/argument_container_reader.rb +97 -0
- data/lib/smith/tool/argument_scalar_snapshot.rb +99 -0
- data/lib/smith/tool/argument_snapshot.rb +19 -0
- data/lib/smith/tool/argument_snapshot_accounting.rb +69 -0
- data/lib/smith/tool/argument_snapshot_result.rb +15 -0
- data/lib/smith/tool/argument_snapshot_traversal.rb +119 -0
- data/lib/smith/tool/bounded_completion_context.rb +131 -0
- data/lib/smith/tool/bounded_completion_controls.rb +113 -0
- data/lib/smith/tool/bounded_completion_guard.rb +101 -0
- data/lib/smith/tool/bounded_completion_installation.rb +20 -0
- data/lib/smith/tool/bounded_completion_state.rb +57 -0
- data/lib/smith/tool/budget_enforcement.rb +15 -7
- data/lib/smith/tool/call_admission.rb +44 -0
- data/lib/smith/tool/call_allowance.rb +101 -28
- data/lib/smith/tool/call_allowance_counter.rb +43 -0
- data/lib/smith/tool/call_batch.rb +47 -0
- data/lib/smith/tool/call_budget.rb +51 -0
- data/lib/smith/tool/call_reservation.rb +42 -0
- data/lib/smith/tool/chat_execution_callbacks.rb +58 -0
- data/lib/smith/tool/chat_execution_context.rb +7 -88
- data/lib/smith/tool/execution_authority.rb +42 -0
- data/lib/smith/tool/execution_authorization.rb +17 -0
- data/lib/smith/tool/execution_batch.rb +100 -0
- data/lib/smith/tool/execution_batch_admission.rb +56 -0
- data/lib/smith/tool/execution_batch_builder.rb +122 -0
- data/lib/smith/tool/execution_batch_collection.rb +70 -0
- data/lib/smith/tool/execution_batch_invocations.rb +28 -0
- data/lib/smith/tool/execution_batch_lifecycle.rb +91 -0
- data/lib/smith/tool/execution_batch_registry.rb +99 -0
- data/lib/smith/tool/execution_batch_requests.rb +59 -0
- data/lib/smith/tool/execution_batch_source_call.rb +36 -0
- data/lib/smith/tool/execution_batch_source_metadata.rb +107 -0
- data/lib/smith/tool/execution_batch_sources.rb +32 -0
- data/lib/smith/tool/execution_batch_state.rb +110 -0
- data/lib/smith/tool/execution_dispatch.rb +78 -0
- data/lib/smith/tool/execution_failure_handling.rb +101 -0
- data/lib/smith/tool/execution_lifecycle.rb +28 -0
- data/lib/smith/tool/execution_tracker.rb +20 -0
- data/lib/smith/tool/fail_fast_completion.rb +24 -0
- data/lib/smith/tool/graceful_completion.rb +20 -0
- data/lib/smith/tool/invocation.rb +27 -0
- data/lib/smith/tool/invocation_request.rb +37 -0
- data/lib/smith/tool/invocation_sequence.rb +26 -0
- data/lib/smith/tool/legacy_call_allowance.rb +57 -0
- data/lib/smith/tool/scoped_context.rb +60 -8
- data/lib/smith/tool.rb +71 -12
- data/lib/smith/tool_execution_not_admitted.rb +7 -0
- data/lib/smith/tool_failure_notification_failed.rb +94 -0
- data/lib/smith/version.rb +2 -2
- data/lib/smith/workflow/agent_result.rb +4 -3
- data/lib/smith/workflow/artifact_integration.rb +2 -1
- data/lib/smith/workflow/composite/effects.rb +3 -1
- data/lib/smith/workflow/deadline_enforcement.rb +20 -2
- data/lib/smith/workflow/execution.rb +2 -9
- data/lib/smith/workflow/failure_detail_snapshot.rb +33 -0
- data/lib/smith/workflow/failure_reconstructor.rb +79 -0
- data/lib/smith/workflow/failure_record.rb +139 -0
- data/lib/smith/workflow/failure_record_restore.rb +145 -0
- data/lib/smith/workflow/failure_record_text.rb +83 -0
- data/lib/smith/workflow/failure_record_validator.rb +113 -0
- data/lib/smith/workflow/guarded_step_execution.rb +39 -0
- data/lib/smith/workflow/parallel.rb +14 -3
- data/lib/smith/workflow/persistence.rb +22 -19
- data/lib/smith/workflow/retry_execution.rb +5 -1
- data/lib/smith/workflow/thread_context_snapshot.rb +5 -2
- data/lib/smith/workflow/transition.rb +9 -4
- data/lib/smith/workflow/usage_entry.rb +12 -2
- data/lib/smith/workflow.rb +17 -130
- data/lib/smith.rb +3 -0
- 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
|
|
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
|
|
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
|