smith-agents 0.11.0 → 0.12.1
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 +712 -0
- data/README.md +622 -15
- data/UPSTREAM_PROPOSAL.md +1 -0
- data/docs/CONFIGURATION.md +80 -14
- data/docs/PATTERNS.md +30 -13
- data/docs/PERSISTENCE.md +733 -5
- data/docs/TOOLS_AND_GUARDRAILS.md +24 -3
- data/lib/generators/smith/install/install_generator.rb +67 -2
- data/lib/generators/smith/install/templates/create_workflow_states.rb.tt +31 -0
- data/lib/generators/smith/install/templates/smith.rb.tt +39 -8
- data/lib/generators/smith/install/templates/workflow_state.rb.tt +19 -0
- data/lib/smith/agent/completion.rb +32 -6
- data/lib/smith/agent/completion_usage_recording.rb +23 -14
- data/lib/smith/agent/content_bytes.rb +98 -0
- data/lib/smith/agent/content_parts.rb +149 -0
- data/lib/smith/agent/invocation_preparation.rb +37 -14
- data/lib/smith/agent/lifecycle.rb +16 -5
- data/lib/smith/agent/observed_model.rb +29 -0
- data/lib/smith/agent/output_contract_check.rb +53 -0
- data/lib/smith/agent/output_integrity.rb +137 -0
- data/lib/smith/agent/output_policy.rb +92 -0
- data/lib/smith/agent/output_recovery.rb +129 -0
- data/lib/smith/agent/provider_attempt.rb +6 -0
- data/lib/smith/agent/provider_call_timing.rb +13 -4
- data/lib/smith/agent/provider_completion.rb +35 -32
- data/lib/smith/agent/provider_failure_handling.rb +5 -1
- data/lib/smith/agent/provider_request.rb +36 -0
- data/lib/smith/agent/provider_streaming.rb +155 -0
- data/lib/smith/agent/provider_usage.rb +70 -3
- data/lib/smith/agent/usage_entry_recording.rb +4 -2
- data/lib/smith/agent/usage_traces.rb +9 -2
- data/lib/smith/agent/usage_tracking.rb +64 -51
- data/lib/smith/agent.rb +5 -0
- data/lib/smith/artifacts/active_record_store.rb +79 -0
- data/lib/smith/artifacts/file.rb +45 -18
- data/lib/smith/artifacts/memory.rb +47 -36
- data/lib/smith/artifacts/s3.rb +126 -0
- data/lib/smith/artifacts/scoped_store.rb +55 -7
- data/lib/smith/artifacts/store_rules.rb +49 -0
- data/lib/smith/artifacts.rb +98 -1
- data/lib/smith/batch/message_shape.rb +36 -0
- data/lib/smith/batch.rb +166 -0
- data/lib/smith/budget/amount.rb +25 -0
- data/lib/smith/budget/continuation.rb +153 -0
- data/lib/smith/budget/host_ledger.rb +137 -0
- data/lib/smith/budget/ledger.rb +4 -2
- data/lib/smith/budget/ledger_state_transition.rb +7 -5
- data/lib/smith/budget/request_hold.rb +211 -0
- data/lib/smith/budget/spent_reservation.rb +18 -0
- data/lib/smith/child_run.rb +41 -0
- data/lib/smith/child_runs_failed.rb +56 -0
- data/lib/smith/content.rb +96 -0
- data/lib/smith/context/session.rb +14 -2
- data/lib/smith/context/state_injection.rb +10 -5
- data/lib/smith/context.rb +28 -0
- data/lib/smith/doctor/checks/artifacts.rb +103 -0
- data/lib/smith/doctor/checks/configuration.rb +0 -11
- data/lib/smith/doctor/checks/durability.rb +20 -18
- data/lib/smith/doctor/checks/state_model.rb +63 -0
- data/lib/smith/doctor/installer.rb +9 -2
- data/lib/smith/doctor.rb +4 -0
- data/lib/smith/errors.rb +249 -2
- data/lib/smith/idempotency_key/canonical.rb +74 -0
- data/lib/smith/idempotency_key.rb +145 -0
- data/lib/smith/launchers/inline.rb +46 -0
- data/lib/smith/message.rb +76 -0
- data/lib/smith/models/tool_routing.rb +47 -44
- data/lib/smith/output_failure.rb +130 -0
- data/lib/smith/persistence_adapters/active_record_exact_store.rb +43 -0
- data/lib/smith/pricing/catalog_validation.rb +91 -0
- data/lib/smith/pricing.rb +95 -64
- data/lib/smith/providers/anthropic/batches.rb +116 -0
- data/lib/smith/providers/batch_exchange.rb +77 -0
- data/lib/smith/providers/openai/responses.rb +45 -20
- data/lib/smith/providers/output_limit.rb +68 -0
- data/lib/smith/providers/request_holds.rb +139 -0
- data/lib/smith/providers/response_facts.rb +108 -0
- data/lib/smith/providers/schema_constraint_check.rb +141 -0
- data/lib/smith/providers/schema_constraints.rb +87 -0
- data/lib/smith/providers/schema_rules.rb +66 -0
- data/lib/smith/providers/stop_reason.rb +39 -0
- data/lib/smith/providers/stream_facts.rb +192 -0
- data/lib/smith/response_memo.rb +152 -0
- data/lib/smith/setting_constructors.rb +47 -0
- data/lib/smith/streaming/json_field.rb +136 -0
- data/lib/smith/streaming/json_string.rb +91 -0
- data/lib/smith/streaming/session.rb +77 -0
- data/lib/smith/streaming/think_tags.rb +67 -0
- data/lib/smith/streaming.rb +64 -0
- data/lib/smith/testing/checks.rb +58 -0
- data/lib/smith/testing/hooks.rb +85 -0
- data/lib/smith/testing/minitest.rb +37 -0
- data/lib/smith/testing/rspec.rb +52 -0
- data/lib/smith/testing/stub.rb +110 -0
- data/lib/smith/testing.rb +158 -0
- data/lib/smith/tool/scoped_context.rb +9 -12
- data/lib/smith/tool.rb +14 -0
- data/lib/smith/trace/logger.rb +1 -1
- data/lib/smith/trace/memory.rb +1 -1
- data/lib/smith/trace/open_telemetry.rb +1 -1
- data/lib/smith/trace.rb +11 -4
- data/lib/smith/untrusted.rb +74 -0
- data/lib/smith/version.rb +2 -2
- data/lib/smith/workflow/active_clock.rb +61 -0
- data/lib/smith/workflow/agent_result.rb +37 -0
- data/lib/smith/workflow/artifact_integration.rb +57 -7
- data/lib/smith/workflow/await_step.rb +42 -0
- data/lib/smith/workflow/batch_charges.rb +74 -0
- data/lib/smith/workflow/batch_execution.rb +149 -0
- data/lib/smith/workflow/batch_holds.rb +73 -0
- data/lib/smith/workflow/batch_wait.rb +134 -0
- data/lib/smith/workflow/budget_integration.rb +11 -5
- data/lib/smith/workflow/budget_pause.rb +51 -0
- data/lib/smith/workflow/child_run_declarations.rb +127 -0
- data/lib/smith/workflow/child_run_execution.rb +150 -0
- data/lib/smith/workflow/child_run_results.rb +55 -0
- data/lib/smith/workflow/composite/effects.rb +37 -6
- data/lib/smith/workflow/composite/error.rb +1 -1
- data/lib/smith/workflow/composite/error_evidence.rb +9 -9
- data/lib/smith/workflow/composite/input.rb +11 -4
- data/lib/smith/workflow/context_keys.rb +77 -0
- data/lib/smith/workflow/deadline_enforcement.rb +1 -1
- data/lib/smith/workflow/deterministic_execution.rb +61 -10
- data/lib/smith/workflow/deterministic_step.rb +55 -6
- data/lib/smith/workflow/dsl.rb +59 -0
- data/lib/smith/workflow/durability.rb +139 -30
- data/lib/smith/workflow/evaluator_optimizer.rb +12 -6
- data/lib/smith/workflow/event_integration.rb +9 -0
- data/lib/smith/workflow/execution.rb +30 -23
- data/lib/smith/workflow/execution_binding_resolution.rb +13 -0
- data/lib/smith/workflow/failure_reconstructor.rb +27 -4
- data/lib/smith/workflow/failure_record.rb +6 -1
- data/lib/smith/workflow/failure_record_validator.rb +11 -4
- data/lib/smith/workflow/fanout_execution.rb +5 -8
- data/lib/smith/workflow/graph/batch_diagnostic.rb +83 -0
- data/lib/smith/workflow/graph/branch_limit_diagnostic.rb +41 -0
- data/lib/smith/workflow/graph/budget_ledger_diagnostic.rb +126 -0
- data/lib/smith/workflow/graph/durable_retry_diagnostic.rb +50 -0
- data/lib/smith/workflow/graph/execution_successors.rb +16 -2
- data/lib/smith/workflow/graph/inherited_untrusted_diagnostics.rb +77 -0
- data/lib/smith/workflow/graph/isolated_input_diagnostic.rb +50 -0
- data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +43 -4
- data/lib/smith/workflow/graph/output_key_diagnostic.rb +53 -0
- data/lib/smith/workflow/graph/output_schema_diagnostic.rb +49 -0
- data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +1 -0
- data/lib/smith/workflow/graph/run_launcher_diagnostic.rb +30 -0
- data/lib/smith/workflow/graph/runtime_binding_diagnostic_builder.rb +9 -3
- data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +7 -3
- data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +8 -1
- data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +9 -2
- data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +7 -4
- data/lib/smith/workflow/graph/stream_diagnostic.rb +95 -0
- data/lib/smith/workflow/graph/transition_contract.rb +19 -0
- data/lib/smith/workflow/graph/transition_contract_attributes.rb +25 -2
- data/lib/smith/workflow/graph/transition_contract_configurations.rb +24 -3
- data/lib/smith/workflow/graph/transition_diagnostics.rb +11 -24
- data/lib/smith/workflow/graph/transition_snapshot.rb +40 -39
- data/lib/smith/workflow/graph/untrusted_input_diagnostic.rb +57 -0
- data/lib/smith/workflow/graph/waiting_child_diagnostic.rb +24 -0
- data/lib/smith/workflow/graph.rb +12 -0
- data/lib/smith/workflow/guarded_step_execution.rb +48 -3
- data/lib/smith/workflow/message_batch.rb +6 -0
- data/lib/smith/workflow/message_roles.rb +25 -0
- data/lib/smith/workflow/message_value_normalizer.rb +2 -1
- data/lib/smith/workflow/nested_execution.rb +47 -4
- data/lib/smith/workflow/orchestrator_worker.rb +9 -3
- data/lib/smith/workflow/parallel_execution.rb +10 -0
- data/lib/smith/workflow/persistence.rb +136 -13
- data/lib/smith/workflow/remembered_calls.rb +92 -0
- data/lib/smith/workflow/retry_execution.rb +68 -9
- data/lib/smith/workflow/retry_wait.rb +134 -0
- data/lib/smith/workflow/split_step_persistence/checkpoint.rb +3 -0
- data/lib/smith/workflow/split_step_persistence/checkpoint_state.rb +7 -0
- data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +4 -1
- data/lib/smith/workflow/split_step_persistence/composite_preparation.rb +3 -1
- data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +1 -0
- data/lib/smith/workflow/split_step_persistence/preparation.rb +5 -0
- data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +1 -0
- data/lib/smith/workflow/step_completion.rb +36 -7
- data/lib/smith/workflow/step_context.rb +21 -0
- data/lib/smith/workflow/step_io.rb +132 -0
- data/lib/smith/workflow/step_stream.rb +52 -0
- data/lib/smith/workflow/thread_context_snapshot.rb +2 -0
- data/lib/smith/workflow/transition.rb +267 -13
- data/lib/smith/workflow/transition_actionability.rb +2 -0
- data/lib/smith/workflow/usage_entry.rb +24 -3
- data/lib/smith/workflow/wait_completion.rb +131 -0
- data/lib/smith/workflow/wait_execution.rb +180 -0
- data/lib/smith/workflow/wait_record.rb +157 -0
- data/lib/smith/workflow.rb +113 -17
- data/lib/smith.rb +101 -45
- metadata +88 -1
|
@@ -242,9 +242,8 @@ specific forced tool takes precedence when only one enabled endpoint can carry
|
|
|
242
242
|
it. If normalization removes a specifically forced tool, Smith clears that
|
|
243
243
|
stale choice and attributes the reset only to that selected tool. An
|
|
244
244
|
endpoint-constrained tool routed to OpenAI Responses inherits the current
|
|
245
|
-
non-streaming Responses contract
|
|
246
|
-
|
|
247
|
-
fallback.
|
|
245
|
+
non-streaming Responses contract: a caller requesting streaming gets the whole
|
|
246
|
+
reply as one chunk, never a silent Chat Completions fallback.
|
|
248
247
|
|
|
249
248
|
Bounded completion ownership and tool-call reservations mask asynchronous
|
|
250
249
|
interrupts while installing and restoring process-local state. User work runs
|
|
@@ -270,6 +269,28 @@ authoritative replay proof. `ordinal` is allocated from one thread-safe sequence
|
|
|
270
269
|
shared by every Smith-managed chat, branch, thread, and fiber inside the current
|
|
271
270
|
host invocation scope.
|
|
272
271
|
|
|
272
|
+
For a key to send with a side-effecting request, a tool running in a workflow
|
|
273
|
+
step calls `idempotency_key(scope)` in `perform`:
|
|
274
|
+
|
|
275
|
+
```ruby
|
|
276
|
+
class SendInvoice < Smith::Tool
|
|
277
|
+
def perform(customer_id:, amount_cents:)
|
|
278
|
+
Billing.send_invoice(customer_id:, amount_cents:,
|
|
279
|
+
idempotency_key: idempotency_key(customer_id:, amount_cents:))
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
The key is unique to the run, the step and branch the tool runs in, the tool's
|
|
285
|
+
`name` and the scope, including in concurrent tool calls, and it is the same
|
|
286
|
+
when a replay sends the same request. The scope should be the request's stable
|
|
287
|
+
identity (customer and amount here), not free text such as a message body:
|
|
288
|
+
the model can call one tool several times in a step, and a replayed step's
|
|
289
|
+
model may word or order its calls differently. Renaming the tool class changes
|
|
290
|
+
its keys. Outside a workflow step it raises `Smith::WorkflowError`. Smith
|
|
291
|
+
supplies the key; holding operation receipts and reconciling outcomes stays
|
|
292
|
+
with the host, as above. See `docs/PERSISTENCE.md`, Replay Safety.
|
|
293
|
+
|
|
273
294
|
Hosts that reconstruct an execution may supply an explicit next ordinal:
|
|
274
295
|
|
|
275
296
|
```ruby
|
|
@@ -1,22 +1,87 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
4
5
|
|
|
5
6
|
module Smith
|
|
7
|
+
# `rails g smith:install`: the initializer, and a table and model for
|
|
8
|
+
# workflow state (Smith's :active_record persistence adapter) unless
|
|
9
|
+
# --skip-state-model.
|
|
6
10
|
class InstallGenerator < Rails::Generators::Base
|
|
11
|
+
include Rails::Generators::Migration
|
|
12
|
+
|
|
13
|
+
# The most a PostgreSQL text column holds (1 GiB less a byte), the
|
|
14
|
+
# payload's limit when no bound is set.
|
|
15
|
+
UNBOUNDED_PAYLOAD = 1_073_741_823
|
|
16
|
+
# The default bound: well above what one step can add to a state (a
|
|
17
|
+
# map's collected results, 4 MiB; a wait's payload or a paused tool
|
|
18
|
+
# loop, 1 MiB each), so it refuses runaway growth, not a run Smith built.
|
|
19
|
+
MAX_STATE_BYTES = 16 * 1024 * 1024
|
|
20
|
+
|
|
7
21
|
source_root File.expand_path("templates", __dir__)
|
|
22
|
+
desc "Writes Smith's initializer, and a table and model for workflow state"
|
|
23
|
+
|
|
24
|
+
class_option :state_model, type: :string, default: "WorkflowState",
|
|
25
|
+
desc: "The model that keeps workflow state"
|
|
26
|
+
class_option :max_state_bytes, type: :numeric, default: MAX_STATE_BYTES,
|
|
27
|
+
desc: "persistence_max_bytes, and the payload CHECK above it (0: neither)"
|
|
28
|
+
class_option :skip_state_model, type: :boolean, default: false,
|
|
29
|
+
desc: "Write only the initializer"
|
|
30
|
+
|
|
31
|
+
def self.next_migration_number(dirname) = ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
32
|
+
|
|
33
|
+
def check_options
|
|
34
|
+
bytes = options[:max_state_bytes]
|
|
35
|
+
return if bytes.is_a?(Integer) && !bytes.negative?
|
|
36
|
+
|
|
37
|
+
raise Thor::Error, "--max-state-bytes must be a whole number of bytes (0 for no bound), got #{bytes.inspect}"
|
|
38
|
+
end
|
|
8
39
|
|
|
9
40
|
def create_smith_initializer
|
|
10
41
|
template "smith.rb.tt", "config/initializers/smith.rb"
|
|
11
42
|
end
|
|
12
43
|
|
|
44
|
+
def create_state_model
|
|
45
|
+
return if options[:skip_state_model]
|
|
46
|
+
|
|
47
|
+
migration_template "create_workflow_states.rb.tt", "db/migrate/create_#{table_name}.rb"
|
|
48
|
+
template "workflow_state.rb.tt", File.join("app/models", "#{state_model.underscore}.rb")
|
|
49
|
+
end
|
|
50
|
+
|
|
13
51
|
def show_next_steps
|
|
14
52
|
say ""
|
|
15
53
|
say "Smith installed. Next steps:", :green
|
|
16
54
|
say " 1. Configure RubyLLM in config/initializers/ruby_llm.rb"
|
|
17
|
-
say " 2. Run: bin/rails
|
|
18
|
-
say "
|
|
55
|
+
say " 2. Run: bin/rails db:migrate" unless options[:skip_state_model]
|
|
56
|
+
say " #{options[:skip_state_model] ? 2 : 3}. Run: bin/rails smith:doctor"
|
|
19
57
|
say ""
|
|
20
58
|
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def state_model = options[:state_model].camelize
|
|
63
|
+
|
|
64
|
+
# The table's name, set on the model: a namespace's own
|
|
65
|
+
# table_name_prefix cannot move it.
|
|
66
|
+
def table_name = state_model.demodulize.underscore.pluralize
|
|
67
|
+
|
|
68
|
+
def namespaces = state_model.split("::")[0...-1]
|
|
69
|
+
|
|
70
|
+
def max_state_bytes = options[:max_state_bytes]
|
|
71
|
+
|
|
72
|
+
# The payload CHECK's bound: past persistence_max_bytes by a third and
|
|
73
|
+
# a little, so Smith's refusal comes first even for a column the host
|
|
74
|
+
# encrypts (Base64 ciphertext with its headers).
|
|
75
|
+
def check_bytes
|
|
76
|
+
(max_state_bytes * 4 / 3) + 4096 if max_state_bytes.positive?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The payload column's limit: MySQL picks its text type by it (TEXT
|
|
80
|
+
# holds only 64 KiB); PostgreSQL refuses one past 1 GiB and otherwise
|
|
81
|
+
# ignores it, as SQLite does.
|
|
82
|
+
def payload_limit = [check_bytes || UNBOUNDED_PAYLOAD, UNBOUNDED_PAYLOAD].min
|
|
83
|
+
|
|
84
|
+
# An Integer as a Ruby literal: 16_777_216.
|
|
85
|
+
def literal(number) = number.to_s.gsub(/\B(?=(\d{3})+(?!\d))/, "_")
|
|
21
86
|
end
|
|
22
87
|
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Workflow runs' stored state, for Smith's :active_record persistence
|
|
4
|
+
# adapter: a key the database keeps unique (child runs' keys are 64
|
|
5
|
+
# characters), the state as JSON text, and the lock_version Smith's writes
|
|
6
|
+
# compare. The payload's limit makes MySQL choose a text type that holds it.
|
|
7
|
+
class Create<%= table_name.camelize %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
8
|
+
def change
|
|
9
|
+
create_table :<%= table_name %> do |t|
|
|
10
|
+
t.string :key, null: false, index: { unique: true }
|
|
11
|
+
t.text :payload, null: false, limit: <%= literal(payload_limit) %>
|
|
12
|
+
t.integer :lock_version, null: false, default: 0
|
|
13
|
+
t.timestamps
|
|
14
|
+
end
|
|
15
|
+
<%- if check_bytes -%>
|
|
16
|
+
|
|
17
|
+
# Smith refuses a state past persistence_max_bytes (<%= literal(max_state_bytes) %>) before
|
|
18
|
+
# writing it; this bounds the stored bytes, with room for an encrypted column.
|
|
19
|
+
add_check_constraint :<%= table_name %>, "#{payload_bytes} <= <%= check_bytes %>",
|
|
20
|
+
name: "<%= table_name %>_payload_size"
|
|
21
|
+
<%- end -%>
|
|
22
|
+
end
|
|
23
|
+
<%- if check_bytes -%>
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def payload_bytes
|
|
28
|
+
connection.adapter_name.match?(/sqlite/i) ? "length(CAST(payload AS BLOB))" : "octet_length(payload)"
|
|
29
|
+
end
|
|
30
|
+
<%- end -%>
|
|
31
|
+
end
|
|
@@ -3,7 +3,21 @@
|
|
|
3
3
|
Smith.configure do |config|
|
|
4
4
|
config.logger = Rails.logger
|
|
5
5
|
|
|
6
|
+
<%- unless options[:skip_state_model] -%>
|
|
7
|
+
# Workflow state in the <%= table_name %> table, through <%= state_model %>.
|
|
8
|
+
config.persistence_adapter = :active_record
|
|
9
|
+
config.persistence_options = { model: "<%= state_model %>" }
|
|
10
|
+
<%- if max_state_bytes.positive? -%>
|
|
11
|
+
# The most bytes a run's state may take: past it Smith raises
|
|
12
|
+
# Smith::PersistedStateTooLarge before writing. The payload column's
|
|
13
|
+
# CHECK sits above it.
|
|
14
|
+
config.persistence_max_bytes = <%= literal(max_state_bytes) %>
|
|
15
|
+
<%- end -%>
|
|
16
|
+
|
|
17
|
+
# Other persistence adapters:
|
|
18
|
+
<%- else -%>
|
|
6
19
|
# Host durability verification / persistence adapter options:
|
|
20
|
+
<%- end -%>
|
|
7
21
|
# config.persistence_adapter = :rails_cache
|
|
8
22
|
# config.persistence_options = { namespace: "smith" }
|
|
9
23
|
#
|
|
@@ -16,29 +30,46 @@ Smith.configure do |config|
|
|
|
16
30
|
# namespace: "smith"
|
|
17
31
|
# }
|
|
18
32
|
#
|
|
19
|
-
|
|
33
|
+
<%- if options[:skip_state_model] -%>
|
|
34
|
+
# config.persistence_adapter = :active_record # `rails g smith:install` writes its table and model
|
|
20
35
|
# config.persistence_options = {
|
|
21
|
-
# model: WorkflowState,
|
|
36
|
+
# model: "WorkflowState",
|
|
22
37
|
# key_column: :key,
|
|
23
38
|
# payload_column: :payload
|
|
24
39
|
# }
|
|
25
40
|
#
|
|
41
|
+
<%- end -%>
|
|
26
42
|
# Custom adapters are also supported if they implement:
|
|
27
43
|
# store(key, payload)
|
|
28
44
|
# fetch(key)
|
|
29
45
|
# delete(key)
|
|
30
46
|
|
|
31
|
-
# Artifact storage for large outputs
|
|
32
|
-
|
|
47
|
+
# Artifact storage for large outputs and the documents steps attach. Like
|
|
48
|
+
# workflow state it must outlive the process for runs that wait, retry or
|
|
49
|
+
# resume elsewhere; with none set, storing or reading an artifact raises
|
|
50
|
+
# Smith::ArtifactStoreMissing (smith:doctor warns).
|
|
51
|
+
#
|
|
52
|
+
# config.artifact_store = :s3 # AWS S3, or Cloudflare R2 (needs the aws-sdk-s3 gem)
|
|
53
|
+
# config.artifact_options = {
|
|
54
|
+
# bucket: "smith-artifacts",
|
|
55
|
+
# endpoint: "https://<account>.r2.cloudflarestorage.com", region: "auto",
|
|
56
|
+
# access_key_id: ENV["R2_ACCESS_KEY_ID"], secret_access_key: ENV["R2_SECRET_ACCESS_KEY"]
|
|
57
|
+
# }
|
|
58
|
+
#
|
|
59
|
+
# config.artifact_store = :active_record # a table: see the PERSISTENCE guide, Artifacts
|
|
60
|
+
# config.artifact_options = { model: "SmithArtifact" }
|
|
61
|
+
#
|
|
62
|
+
# config.artifact_store = :memory # one process only, e.g. in tests
|
|
33
63
|
|
|
34
64
|
# Trace adapter (Smith::Trace::Memory, Smith::Trace::Logger, Smith::Trace::OpenTelemetry)
|
|
35
65
|
config.trace_adapter = Smith::Trace::Logger
|
|
36
66
|
|
|
37
67
|
# Best-known model-call cost tracking (optional)
|
|
38
|
-
# config.pricing =
|
|
39
|
-
#
|
|
68
|
+
# config.pricing = Smith::Pricing.validate_catalog!(
|
|
69
|
+
# %w[openai gpt-4.1-nano] => {
|
|
40
70
|
# input_cost_per_token: 0.0000001,
|
|
41
|
-
# output_cost_per_token: 0.0000004
|
|
71
|
+
# output_cost_per_token: 0.0000004,
|
|
72
|
+
# cache_read_cost_per_token: 0.000000025 # optional; 0 when absent
|
|
42
73
|
# }
|
|
43
|
-
#
|
|
74
|
+
# )
|
|
44
75
|
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
<%- namespaces.each_with_index do |namespace, depth| -%>
|
|
4
|
+
<%= " " * depth %>module <%= namespace %>
|
|
5
|
+
<%- end -%>
|
|
6
|
+
<%- indent = " " * namespaces.length -%>
|
|
7
|
+
<%= indent %># A workflow run's stored state (Smith's :active_record persistence
|
|
8
|
+
<%= indent %># adapter). Keep it to persistence: Smith writes it under optimistic
|
|
9
|
+
<%= indent %># locking, and a callback that raises after the commit leaves a write it
|
|
10
|
+
<%= indent %># cannot confirm. An encrypted payload (`encrypts`) holds runs, but not
|
|
11
|
+
<%= indent %># split-step exact writes (smith doctor warns).
|
|
12
|
+
<%= indent %>class <%= state_model.demodulize %> < ApplicationRecord
|
|
13
|
+
<%- unless namespaces.empty? -%>
|
|
14
|
+
<%= indent %> self.table_name = "<%= table_name %>"
|
|
15
|
+
<%- end -%>
|
|
16
|
+
<%= indent %>end
|
|
17
|
+
<%- namespaces.length.times.reverse_each do |depth| -%>
|
|
18
|
+
<%= " " * depth %>end
|
|
19
|
+
<%- end -%>
|
|
@@ -10,18 +10,27 @@ module Smith
|
|
|
10
10
|
attribute :response, Types::Any
|
|
11
11
|
attribute :provider_usages, Types::Array.of(Types.Instance(ProviderUsage))
|
|
12
12
|
attribute :usage_complete, Types::Bool
|
|
13
|
+
# The stop reason and message id of the last provider response,
|
|
14
|
+
# whether or not that response reported usage.
|
|
15
|
+
attribute :response_facts, Types::Hash.default({}.freeze)
|
|
13
16
|
|
|
14
|
-
|
|
17
|
+
# `stream` is set for a streamed call: the facts of each of its
|
|
18
|
+
# requests, read from the stream, and the request's output limit
|
|
19
|
+
# (Providers::StreamFacts). Each response's usage carries its facts and
|
|
20
|
+
# `mode: :stream`. `mode` marks another call's usage (:batch).
|
|
21
|
+
def self.from_messages(response:, messages:, stream: nil, mode: nil)
|
|
15
22
|
assistant_messages = Array(messages).select do |message|
|
|
16
23
|
message.respond_to?(:role) && message.role.to_s == "assistant"
|
|
17
24
|
end
|
|
18
25
|
usage_source = assistant_messages.empty? ? [response] : assistant_messages
|
|
26
|
+
facts = response_facts(usage_source, stream)
|
|
19
27
|
|
|
20
|
-
provider_usages = build_provider_usages(usage_source)
|
|
28
|
+
provider_usages = build_provider_usages(usage_source, stream && facts, mode)
|
|
21
29
|
new(
|
|
22
30
|
response: response,
|
|
23
31
|
provider_usages:,
|
|
24
|
-
usage_complete: provider_usages.length == usage_source.length
|
|
32
|
+
usage_complete: provider_usages.length == usage_source.length,
|
|
33
|
+
response_facts: facts.last
|
|
25
34
|
)
|
|
26
35
|
end
|
|
27
36
|
|
|
@@ -37,10 +46,27 @@ module Smith
|
|
|
37
46
|
provider_usages.sum(&:output_tokens) if usage_complete
|
|
38
47
|
end
|
|
39
48
|
|
|
40
|
-
|
|
41
|
-
|
|
49
|
+
# The whole completion's usage, cache and thinking counts and the
|
|
50
|
+
# final response's stop reason included; nil unless every response
|
|
51
|
+
# reported usage.
|
|
52
|
+
def usage
|
|
53
|
+
ProviderUsage.sum(provider_usages) if usage_complete
|
|
42
54
|
end
|
|
43
|
-
|
|
55
|
+
|
|
56
|
+
def self.response_facts(messages, stream)
|
|
57
|
+
return messages.map { Providers::ResponseFacts.from_message(_1) } unless stream
|
|
58
|
+
|
|
59
|
+
Providers::StreamFacts.per_message(messages, stream.fetch(:rounds), stream.fetch(:output_limit))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.build_provider_usages(messages, streamed_facts, mode)
|
|
63
|
+
messages.each_with_index.filter_map do |message, index|
|
|
64
|
+
usage = ProviderUsage.from_message(message)
|
|
65
|
+
usage = usage.streamed(streamed_facts[index]) if streamed_facts && usage
|
|
66
|
+
mode && usage ? usage.new(mode:) : usage
|
|
67
|
+
end.freeze
|
|
68
|
+
end
|
|
69
|
+
private_class_method :response_facts, :build_provider_usages
|
|
44
70
|
end
|
|
45
71
|
end
|
|
46
72
|
end
|
|
@@ -5,33 +5,42 @@ module Smith
|
|
|
5
5
|
module CompletionUsageRecording
|
|
6
6
|
private
|
|
7
7
|
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
|
|
14
|
-
# and every usage priced; a partial sum is still returned (it is what
|
|
15
|
-
# was verifiably billed) but callers must not present it as the
|
|
16
|
-
# complete invocation cost.
|
|
17
|
-
def record_completion_usage(agent_class, completion, attempt_kind, model_reference, attempt_id: nil)
|
|
8
|
+
# Records and prices one usage entry per provider response and returns
|
|
9
|
+
# the per-response AgentResults: per-response pricing is what tiered
|
|
10
|
+
# catalogs bill, so an invocation's cost is the sum of these, never the
|
|
11
|
+
# aggregate token totals priced as one call (which would resolve the
|
|
12
|
+
# wrong tier for a multi-response tool loop).
|
|
13
|
+
def record_response_usages(agent_class, usages, attempt_kind, model_reference, attempt_id: nil)
|
|
18
14
|
model_reference = coerce_model_reference(model_reference)
|
|
19
|
-
|
|
15
|
+
usages.map do |usage|
|
|
20
16
|
result = Workflow::AgentResult.new(
|
|
21
17
|
content: nil,
|
|
22
18
|
input_tokens: usage.input_tokens,
|
|
23
19
|
output_tokens: usage.output_tokens,
|
|
24
20
|
cost: nil,
|
|
25
21
|
model_used: model_reference.model_id,
|
|
26
|
-
provider_used: model_reference.provider
|
|
22
|
+
provider_used: model_reference.provider,
|
|
23
|
+
**Workflow::AgentResult.usage_details(usage)
|
|
27
24
|
)
|
|
28
25
|
compute_agent_cost(result)
|
|
29
26
|
record_usage(agent_class, result, attempt_kind, model_reference, attempt_id:)
|
|
30
|
-
result
|
|
27
|
+
result
|
|
31
28
|
end
|
|
32
|
-
summarize_invocation_costs(costs, completion)
|
|
33
29
|
end
|
|
34
30
|
|
|
31
|
+
# Returns [invocation_cost, fully_priced, per_response_results].
|
|
32
|
+
def record_completion_usage(agent_class, completion, attempt_kind, model_reference, attempt_id: nil)
|
|
33
|
+
results = record_response_usages(agent_class, completion.provider_usages, attempt_kind, model_reference,
|
|
34
|
+
attempt_id:)
|
|
35
|
+
[*summarize_invocation_costs(results.map(&:cost), completion), results]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Returns [invocation_cost, fully_priced]. The cost is the sum of the
|
|
39
|
+
# priced responses (nil when none was priced). fully_priced is true
|
|
40
|
+
# only when every provider response carried usage and every usage
|
|
41
|
+
# priced; a partial sum is still returned (it is what was verifiably
|
|
42
|
+
# billed) but callers must not present it as the complete invocation
|
|
43
|
+
# cost.
|
|
35
44
|
def summarize_invocation_costs(costs, completion)
|
|
36
45
|
priced = costs.compact
|
|
37
46
|
invocation_cost = priced.empty? ? nil : priced.sum
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Agent
|
|
5
|
+
# The documents and images of one agent invocation, read from the run's
|
|
6
|
+
# artifacts once, before its first attempt, and checked: readable, a
|
|
7
|
+
# non-empty String, of the kind its media type names (a PDF, PNG, JPEG,
|
|
8
|
+
# GIF or WebP by its leading bytes, `text/plain` as UTF-8), an image at
|
|
9
|
+
# most MAX_IMAGE_BYTES and any part at most Content::MAX_PART_BYTES, and
|
|
10
|
+
# the request's text with its documents and images as they are sent
|
|
11
|
+
# (PDFs and images in base64) at most MAX_REQUEST_BYTES. Anything else
|
|
12
|
+
# raises Smith::ContentUnavailable; the store's own errors pass on.
|
|
13
|
+
# Held per thread for the invocation, so parallel branches read their
|
|
14
|
+
# own.
|
|
15
|
+
module ContentBytes
|
|
16
|
+
MAX_IMAGE_BYTES = 5 * 1024 * 1024
|
|
17
|
+
MAX_REQUEST_BYTES = 32 * 1024 * 1024
|
|
18
|
+
SIGNATURES = {
|
|
19
|
+
"application/pdf" => ->(bytes) { bytes.start_with?("%PDF-".b) },
|
|
20
|
+
"image/png" => ->(bytes) { bytes.start_with?("\x89PNG\r\n\x1A\n".b) },
|
|
21
|
+
"image/jpeg" => ->(bytes) { bytes.start_with?("\xFF\xD8\xFF".b) },
|
|
22
|
+
"image/gif" => ->(bytes) { bytes.start_with?("GIF87a".b, "GIF89a".b) },
|
|
23
|
+
"image/webp" => ->(bytes) { bytes.byteslice(0, 4) == "RIFF".b && bytes.byteslice(8, 4) == "WEBP".b },
|
|
24
|
+
"text/plain" => ->(bytes) { bytes.dup.force_encoding(Encoding::UTF_8).valid_encoding? }
|
|
25
|
+
}.freeze
|
|
26
|
+
HELD = :smith_content_bytes
|
|
27
|
+
private_constant :SIGNATURES, :HELD
|
|
28
|
+
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
def with(messages)
|
|
32
|
+
outer = Thread.current[HELD]
|
|
33
|
+
Thread.current[HELD] = read(Array(messages))
|
|
34
|
+
yield
|
|
35
|
+
ensure
|
|
36
|
+
Thread.current[HELD] = outer
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# A part's bytes, as the invocation read them, or read now.
|
|
40
|
+
def fetch(part)
|
|
41
|
+
held = Thread.current[HELD]
|
|
42
|
+
key = [part["artifact"], part["media_type"]]
|
|
43
|
+
held&.key?(key) ? held.fetch(key) : checked_bytes(part)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def read(messages)
|
|
47
|
+
held = {}
|
|
48
|
+
total = 0
|
|
49
|
+
messages.each do |message|
|
|
50
|
+
next unless Message.parts?(message)
|
|
51
|
+
|
|
52
|
+
(message[:content] || message["content"]).map { |part| Content.check!(part) }.each do |part|
|
|
53
|
+
total += sent_bytes(part, held)
|
|
54
|
+
next if total <= MAX_REQUEST_BYTES
|
|
55
|
+
|
|
56
|
+
raise ContentUnavailable, "the request's text, documents and images come to more than " \
|
|
57
|
+
"#{MAX_REQUEST_BYTES} bytes as sent"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
held.freeze
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def sent_bytes(part, held)
|
|
64
|
+
return part["text"].bytesize if part["type"] == "text"
|
|
65
|
+
|
|
66
|
+
bytes = (held[[part["artifact"], part["media_type"]]] ||= checked_bytes(part))
|
|
67
|
+
part["media_type"] == "text/plain" ? bytes.bytesize : 4 * ((bytes.bytesize + 2) / 3)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def checked_bytes(part)
|
|
71
|
+
bytes = stored_bytes(part)
|
|
72
|
+
limit = part["type"] == "image" ? MAX_IMAGE_BYTES : Content::MAX_PART_BYTES
|
|
73
|
+
problem = if !bytes.is_a?(String) then "is missing or expired, or not a String"
|
|
74
|
+
elsif bytes.empty? then "is empty"
|
|
75
|
+
elsif bytes.bytesize > limit then "is larger than #{limit} bytes"
|
|
76
|
+
elsif !SIGNATURES.fetch(part["media_type"]).call(bytes.b) then "is not #{part["media_type"]}"
|
|
77
|
+
end
|
|
78
|
+
return bytes unless problem
|
|
79
|
+
|
|
80
|
+
raise ContentUnavailable, "the artifact of a #{part["type"]} part #{problem}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Through the run's scoped store, which must be able to tell which run
|
|
84
|
+
# stored a ref. The store's own errors (a timeout, a missing store)
|
|
85
|
+
# pass on as they are, before any provider attempt, so `retry_on` can
|
|
86
|
+
# take a transient one.
|
|
87
|
+
def stored_bytes(part)
|
|
88
|
+
store = Smith.scoped_artifacts
|
|
89
|
+
unless store.respond_to?(:vouches?) && store.vouches?
|
|
90
|
+
raise ContentUnavailable, "content parts are read inside a step, from an artifact store that records " \
|
|
91
|
+
"which run stored each artifact (stored_by?)"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
store.fetch(part["artifact"])
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "stringio"
|
|
5
|
+
|
|
6
|
+
module Smith
|
|
7
|
+
class Agent
|
|
8
|
+
# A message with content parts (Smith::Message) as the provider takes
|
|
9
|
+
# it, built for each attempt's chat. Anthropic gets native blocks
|
|
10
|
+
# (`RubyLLM::Content::Raw`), a cached part with `cache_control`, at most
|
|
11
|
+
# MAX_CACHE_BREAKPOINTS cached blocks in the request (Smith's and the
|
|
12
|
+
# chat's own). Any other provider gets the parts as one text, in order,
|
|
13
|
+
# with a `text/plain` document inline and each PDF or image as an
|
|
14
|
+
# attachment named where it stands in the text; `cache:` is ignored.
|
|
15
|
+
#
|
|
16
|
+
# The documents and images of one invocation are read from the run's
|
|
17
|
+
# artifacts once, before the first attempt (ContentBytes), so a store
|
|
18
|
+
# failure is never taken for a provider failure.
|
|
19
|
+
module ContentParts
|
|
20
|
+
MAX_CACHE_BREAKPOINTS = 4
|
|
21
|
+
EXTENSIONS = { "application/pdf" => "pdf", "text/plain" => "txt", "image/png" => "png",
|
|
22
|
+
"image/jpeg" => "jpg", "image/gif" => "gif", "image/webp" => "webp" }.freeze
|
|
23
|
+
private_constant :MAX_CACHE_BREAKPOINTS, :EXTENSIONS
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
# Counts the cached blocks already in the chat (its instructions) and
|
|
28
|
+
# those the prepared input adds, before any is added.
|
|
29
|
+
def check_cache_breakpoints!(chat, messages, provider)
|
|
30
|
+
return unless anthropic?(provider)
|
|
31
|
+
|
|
32
|
+
cached = chat_cache_controls(chat) +
|
|
33
|
+
messages.sum { |message| message_parts(message).count { |part| Content.cached?(part) } }
|
|
34
|
+
return if cached <= MAX_CACHE_BREAKPOINTS
|
|
35
|
+
|
|
36
|
+
raise ContentUnavailable, "#{cached} blocks ask to be cached; Anthropic takes at most " \
|
|
37
|
+
"#{MAX_CACHE_BREAKPOINTS} cache breakpoints in one request"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def check_chain_cache_breakpoints!(chain, prepared_input)
|
|
41
|
+
return unless chain.any? { |reference| anthropic?(reference.provider) }
|
|
42
|
+
|
|
43
|
+
check_cache_breakpoints!(nil, Array(prepared_input), :anthropic)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# A model the chain reaches whose provider takes no such content.
|
|
47
|
+
def unsupported_content?(error) = error.is_a?(::RubyLLM::UnsupportedAttachmentError)
|
|
48
|
+
|
|
49
|
+
def chat_cache_controls(chat)
|
|
50
|
+
return 0 unless chat.respond_to?(:messages)
|
|
51
|
+
|
|
52
|
+
chat.messages.sum do |message|
|
|
53
|
+
content = message.respond_to?(:content) ? message.content : nil
|
|
54
|
+
content.is_a?(::RubyLLM::Content::Raw) ? Array(content.value).count { |block| cached_block?(block) } : 0
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def cached_block?(block) = block.is_a?(Hash) && !(block[:cache_control] || block["cache_control"]).nil?
|
|
59
|
+
|
|
60
|
+
# A parts message's parts: checked where they came in (input:, seeds,
|
|
61
|
+
# restore) and again before the first attempt (ContentBytes).
|
|
62
|
+
def message_parts(message)
|
|
63
|
+
return [] unless Message.parts?(message)
|
|
64
|
+
|
|
65
|
+
message[:content] || message["content"]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
BUILT = :smith_built_contents
|
|
69
|
+
private_constant :BUILT
|
|
70
|
+
|
|
71
|
+
# The contents Smith builds while it adds one prepared input to a
|
|
72
|
+
# chat, held per thread for that call.
|
|
73
|
+
def with_built_contents
|
|
74
|
+
outer = Thread.current[BUILT]
|
|
75
|
+
Thread.current[BUILT] = {}.compare_by_identity
|
|
76
|
+
yield
|
|
77
|
+
ensure
|
|
78
|
+
Thread.current[BUILT] = outer
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The message with its parts as `provider` takes them; the content is
|
|
82
|
+
# recorded as Smith's own, so it passes to the chat unencoded.
|
|
83
|
+
def provider_parts_message(message, provider)
|
|
84
|
+
parts = message_parts(message)
|
|
85
|
+
content = anthropic?(provider) ? anthropic_content(parts) : provider_content(parts)
|
|
86
|
+
Thread.current[BUILT]&.store(content, true)
|
|
87
|
+
{ role: Workflow::MessageRoles.role(message).to_sym, content: }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def built_content?(content) = Thread.current[BUILT]&.key?(content) || false
|
|
91
|
+
|
|
92
|
+
def anthropic?(provider) = provider.to_s == "anthropic"
|
|
93
|
+
|
|
94
|
+
def anthropic_content(parts)
|
|
95
|
+
::RubyLLM::Content::Raw.new(parts.map { |part| anthropic_block(part) })
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def anthropic_block(part)
|
|
99
|
+
block = case part["type"]
|
|
100
|
+
when "text" then { type: "text", text: part["text"] }
|
|
101
|
+
when "image" then { type: "image", source: base64_source(part) }
|
|
102
|
+
else document_block(part)
|
|
103
|
+
end
|
|
104
|
+
Content.cached?(part) ? block.merge(cache_control: { type: "ephemeral" }) : block
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def document_block(part)
|
|
108
|
+
if part["media_type"] == "text/plain"
|
|
109
|
+
return { type: "document", source: { type: "text", media_type: "text/plain", data: artifact_text(part) } }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
{ type: "document", source: base64_source(part) }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def base64_source(part)
|
|
116
|
+
{ type: "base64", media_type: part["media_type"], data: Base64.strict_encode64(artifact_bytes(part)) }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The parts in order as one text: text parts as they are, a
|
|
120
|
+
# `text/plain` document inline, and a marker where each PDF or image
|
|
121
|
+
# stands, attached under the marker's name.
|
|
122
|
+
def provider_content(parts)
|
|
123
|
+
texts = []
|
|
124
|
+
attachments = []
|
|
125
|
+
parts.each.with_index(1) { |part, position| add_provider_part(part, position, texts, attachments) }
|
|
126
|
+
text = texts.join("\n\n")
|
|
127
|
+
return text if attachments.empty?
|
|
128
|
+
|
|
129
|
+
::RubyLLM::Content.new(text.empty? ? nil : text, attachments)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def add_provider_part(part, position, texts, attachments)
|
|
133
|
+
return texts << part["text"] if part["type"] == "text"
|
|
134
|
+
|
|
135
|
+
name = "#{part["type"]}-#{position}.#{EXTENSIONS.fetch(part["media_type"])}"
|
|
136
|
+
if part["media_type"] == "text/plain"
|
|
137
|
+
texts << "<file name='#{name}' mime_type='text/plain'>#{artifact_text(part)}</file>"
|
|
138
|
+
else
|
|
139
|
+
texts << "[attached: #{name}]"
|
|
140
|
+
attachments << ::RubyLLM::Attachment.new(StringIO.new(artifact_bytes(part)), filename: name)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def artifact_text(part) = artifact_bytes(part).dup.force_encoding(Encoding::UTF_8)
|
|
145
|
+
|
|
146
|
+
def artifact_bytes(part) = ContentBytes.fetch(part)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|