smith-agents 0.12.2 → 0.12.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 746bb384c941e4a7276937eec58e74e1b1f11230e7310190a64401e348a92f61
4
- data.tar.gz: 26b5e327966a11488d166214a0363bc2f554a79230b54392775609bcc2d25b6f
3
+ metadata.gz: 0a97b202bc606c58889517b16ffe906f8775d003b9386d371b63ea08dd725dff
4
+ data.tar.gz: 5ad614925043a70182362086dd2c03e9d3cc3bd6eea645b8d04b4e2d61b93719
5
5
  SHA512:
6
- metadata.gz: 652ec2c82ed914ce2aa798ccd87ee6a70afbc9149b731bd16e2ff2a14eecef13c23161b2039d46a5943c2a0eb4bcf9135b48c1ba8308e8585fba827aded3cdc7
7
- data.tar.gz: 2f86f38c713ce0e62cd01ebfbc7b62270d30c30a34f8db2954526c73a9996be0792fd3ef874dbc500e2489883a178e267215b17b53494aaab648d3f9bd612f70
6
+ metadata.gz: eb8e78bc96e382495c098bbb2f5e794a8efe674d5c662715d18f6d6b1b2445d2ba69198b6e447849f509f5cc58fc06ed001162f18829c485b3a1456eb843cfe0
7
+ data.tar.gz: 6f6112a5048ab240fe3658b6107d5e2f950945045f4518e2f07f16a72e40cb1b91a2b0ea31278df09c4fde3329132bfd5cf7dceef76040f00d5d007e01ceebea
data/CHANGELOG.md CHANGED
@@ -4,6 +4,33 @@ All notable changes to Smith are documented in this file.
4
4
 
5
5
  Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Smith is pre-1.0 and under active development; expect occasional contract tightening between minor versions until 1.0.
6
6
 
7
+ ## [0.12.3] - 2026-10-12
8
+
9
+ ### Changed
10
+
11
+ - What identifies a request leaves prompt-cache markers out
12
+ (`Smith::CacheMarkers`): the response memo's key, a batch request's digest
13
+ and a seed's drift digest (`seed_validation`). A marker says how a request
14
+ is billed, never what it asks, so a host may decide call by call whether to
15
+ cache a part (an agent's instructions, a content part, the whole request)
16
+ and a replay still finds what the first call got, as long as the part keeps
17
+ its form (the same text block, with or without its marker). Only the places
18
+ Anthropic puts `cache_control` are looked at (the request, its system and
19
+ message blocks, a tool result's blocks, its tools) and Smith's own
20
+ `"cache" => true` on a seed's content parts; a tool's input or schema, or
21
+ anything else a request carries as data, is never touched. A batch result
22
+ for a request that differs from the one sent only in its markers is no
23
+ longer taken as a changed request.
24
+
25
+ ### Upgrading
26
+
27
+ - Response memo records kept by 0.12.2 for calls that carried a cache marker
28
+ are not found after the upgrade (their key took the marker in), so such a
29
+ call cut by the upgrade's deploy is paid for once more. A batch request
30
+ captured by 0.12.2, and a run's seed digest taken by 0.12.2, still match as
31
+ they were taken, cache markers included: one whose markers change after the
32
+ upgrade counts as changed, as before.
33
+
7
34
  ## [0.12.2] - 2026-10-12
8
35
 
9
36
  ### Added
data/README.md CHANGED
@@ -30,7 +30,7 @@ What you get:
30
30
 
31
31
  ```ruby
32
32
  # Gemfile
33
- gem "smith-agents", "0.12.2", require: "smith"
33
+ gem "smith-agents", "0.12.3", require: "smith"
34
34
  ```
35
35
 
36
36
  In a Rails app:
data/docs/AGENTS.md CHANGED
@@ -216,7 +216,10 @@ without sleeping: `Smith::Providers::Anthropic::Batches` gives the host
216
216
  `submit`, `status`, `results` and `cancel`. `resume!` with
217
217
  `{custom_id => result}` runs the step again, each call taking its result as
218
218
  the reply it would have got. A call that renders another request by then (a
219
- date in its prompt, data read afresh, a prompt changed in a deploy) does what
219
+ date in its prompt, data read afresh, a prompt changed in a deploy; a prompt
220
+ cache marker added or dropped is no change, as it never changes what the
221
+ request asks, though a request captured by Smith 0.12.2 is matched as it was
222
+ sent, markers included) does what
220
223
  its step's `on_request_change:` says: `:keep` (the default) takes the result,
221
224
  which answers the request as sent, and its `:provider_call` trace says
222
225
  `request_changed: true`; `:resend` sends the request as rendered now over the
@@ -138,10 +138,12 @@ attempts it runs under (its step's, and each enclosing run's for a nested
138
138
  workflow, so a retry never takes the attempt before's answer), its attempt
139
139
  index (its model in the fallback chain, or a follow-up's place), its place
140
140
  among the attempt's calls, and a digest of its request (model, messages,
141
- tools with their descriptions and parameters, schema, params). A replay
142
- reaches the same key; a call whose request changed since (a deployed prompt or
143
- tool, another model, a date in the prompt) misses and is sent again, as a miss
144
- always may be.
141
+ tools with their descriptions and parameters, schema, params), Anthropic's
142
+ prompt-cache markers aside: whether a part is cached never changes what the
143
+ call asks, so a host may decide it call by call, keeping the part's form (the
144
+ same text block, with or without its marker). A replay reaches the same key; a
145
+ call whose request changed since (a deployed prompt or tool, another model, a
146
+ date in the prompt) misses and is sent again, as a miss always may be.
145
147
 
146
148
  Its record (`{"version" => 1 | 2, "messages" => [...], "complete" => true |
147
149
  false}`, JSON) holds the messages the call added: the model's replies
data/lib/smith/batch.rb CHANGED
@@ -45,16 +45,18 @@ module Smith
45
45
  deep_freeze(JSON.parse(encoded))
46
46
  end
47
47
 
48
- # What identifies a request's content: a SHA-256 of its payload and
49
- # headers as JSON gives them, so the request a delivery answers can be
50
- # told from one the step would render now.
51
- def digest(request) = digest_of(request.fetch("params"), request.fetch("headers"))
52
-
53
- # Whether a call renders a request other than the one sent (`digest`).
54
- # Nothing is sent, so nothing in it is refused; one JSON cannot give
55
- # counts as changed.
48
+ # What identifies a request's content: a SHA-256 of its payload, prompt
49
+ # cache markers aside (CacheMarkers), and headers as JSON gives them, so
50
+ # the request a delivery answers can be told from one the step would
51
+ # render now.
52
+ def digest(request) = digest_of(CacheMarkers.strip_request(request.fetch("params")), request.fetch("headers"))
53
+
54
+ # Whether a call renders a request other than the one sent (`digest`),
55
+ # cache markers aside; a digest Smith 0.12.2 took with its markers still
56
+ # matches the request as it was. Nothing is sent, so nothing in it is
57
+ # refused; one JSON cannot give counts as changed.
56
58
  def changed?(digest, params, headers)
57
- digest_of(params, headers.to_h) != digest
59
+ [CacheMarkers.strip_request(params), params].none? { digest_of(_1, headers.to_h) == digest }
58
60
  rescue JSON::GeneratorError, JSON::NestingError, Encoding::UndefinedConversionError
59
61
  true
60
62
  end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ # Anthropic's prompt-cache markers (`cache_control: { type: "ephemeral" }`
5
+ # on a request, a system block, a content block, a tool result's block or
6
+ # a tool) and Smith's own `"cache" => true` on a content part say how a
7
+ # request is billed, never what it asks: the reply is the same with or
8
+ # without them. What identifies a request (the response memo's key, a batch
9
+ # request's digest, a seed's drift digest) leaves them out, so a host that
10
+ # caches a prompt on one call and not on the next still finds what the
11
+ # first call got. Only the places markers can sit are looked at: a tool's
12
+ # input or schema, or anything else a request carries as data, is never
13
+ # touched, and nothing is walked deeper than a tool result's blocks.
14
+ module CacheMarkers
15
+ MARKER_KEYS = [:cache_control, "cache_control"].freeze
16
+ private_constant :MARKER_KEYS
17
+
18
+ module_function
19
+
20
+ # A request payload (a batch request's params, a chat's params): its own
21
+ # marker, and those of its system blocks, its messages' blocks and its
22
+ # tools.
23
+ def strip_request(request)
24
+ return request unless request.is_a?(Hash)
25
+
26
+ request = unmarked(request)
27
+ request = replaced(request, :system) { strip_blocks(_1) }
28
+ request = replaced(request, :messages) do |messages|
29
+ messages.is_a?(Array) ? messages.map { |message| replaced(message, :content) { strip_blocks(_1) } } : messages
30
+ end
31
+ replaced(request, :tools) { |tools| tools.is_a?(Array) ? tools.map { unmarked(_1) } : tools }
32
+ end
33
+
34
+ # A message's content: a block or blocks, a tool result's own blocks
35
+ # included.
36
+ def strip_blocks(content)
37
+ case content
38
+ when Array then content.map { strip_block(_1) }
39
+ when Hash then strip_block(content)
40
+ else content
41
+ end
42
+ end
43
+
44
+ # Seed messages as Smith keeps them: each content part without its
45
+ # `"cache" => true`.
46
+ def strip_parts(messages)
47
+ return messages unless messages.is_a?(Array)
48
+
49
+ messages.map do |message|
50
+ replaced(message, :content) { |content| content.is_a?(Array) ? content.map { unflagged(_1) } : content }
51
+ end
52
+ end
53
+
54
+ def unflagged(part) = part.is_a?(Hash) ? part.except("cache", :cache) : part
55
+
56
+ def strip_block(block)
57
+ return block unless block.is_a?(Hash)
58
+
59
+ block = unmarked(block)
60
+ return block unless (block[:type] || block["type"]).to_s == "tool_result"
61
+
62
+ replaced(block, :content) { |content| content.is_a?(Array) ? content.map { unmarked(_1) } : unmarked(content) }
63
+ end
64
+
65
+ def unmarked(value)
66
+ return value unless value.is_a?(Hash)
67
+
68
+ value.reject { |key, item| MARKER_KEYS.include?(key) && marker?(item) }
69
+ end
70
+
71
+ def marker?(value) = value.is_a?(Hash) && (value[:type] || value["type"]).to_s == "ephemeral"
72
+
73
+ # The Hash with its value under `key` (a Symbol or a String key) passed
74
+ # through the block.
75
+ def replaced(hash, key)
76
+ return hash unless hash.is_a?(Hash)
77
+
78
+ [key, key.to_s].each do |name|
79
+ hash = hash.merge(name => yield(hash[name])) if hash.key?(name)
80
+ end
81
+ hash
82
+ end
83
+
84
+ private_class_method :strip_block, :unflagged, :unmarked, :marker?, :replaced
85
+ end
86
+ end
@@ -149,7 +149,7 @@ module Smith
149
149
 
150
150
  def request_digest(chat)
151
151
  shape = [chat.model.id, chat.model.provider.to_s, chat.messages.map { message_shape(_1) },
152
- tools_shape(chat.tools), chat.schema, chat.params]
152
+ tools_shape(chat.tools), chat.schema, CacheMarkers.strip_request(chat.params)]
153
153
  Digest::SHA256.hexdigest(JSON.generate(shape))
154
154
  rescue StandardError
155
155
  nil
@@ -158,7 +158,7 @@ module Smith
158
158
  def message_shape(message)
159
159
  content = message.content
160
160
  body = case content
161
- when RubyLLM::Content::Raw then content.value
161
+ when RubyLLM::Content::Raw then CacheMarkers.strip_blocks(content.value)
162
162
  when RubyLLM::Content
163
163
  [content.text, content.attachments.map { [_1.filename, _1.mime_type, _1.content.to_s.bytesize] }]
164
164
  else content
data/lib/smith/version.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Smith
4
- VERSION = "0.12.2"
4
+ VERSION = "0.12.3"
5
5
  EXECUTION_SEMANTICS_VERSION = "7"
6
6
  end
@@ -228,13 +228,16 @@ module Smith
228
228
  validate_step_in_progress!(fields.to_h { |field| [field, state[field] || state[field.to_s]] })
229
229
  end
230
230
 
231
+ # A digest Smith before 0.12.3 took, its parts' cache flags included.
232
+ def flagged_seed_digest?(messages, stored) = compute_seed_digest(messages, cache_flags: true) == stored
233
+
231
234
  def validate_seed_digest!(normalized)
232
235
  stored_digest = normalized[:seed_digest]
233
236
  return if stored_digest.nil?
234
237
 
235
238
  current_messages = compute_seed_messages
236
239
  current_digest = compute_seed_digest(current_messages)
237
- return if current_digest == stored_digest
240
+ return if current_digest == stored_digest || flagged_seed_digest?(current_messages, stored_digest)
238
241
 
239
242
  case self.class.seed_validation
240
243
  when :strict
@@ -412,10 +412,12 @@ module Smith
412
412
  normalize_seed_messages(seeded)
413
413
  end
414
414
 
415
- def compute_seed_digest(messages)
415
+ # A seed's drift digest, its parts' cache flags aside (CacheMarkers);
416
+ # with them, as Smith before 0.12.3 took it.
417
+ def compute_seed_digest(messages, cache_flags: false)
416
418
  return nil if messages.nil? || messages.empty?
417
419
 
418
- Digest::SHA256.hexdigest(JSON.generate(messages))
420
+ Digest::SHA256.hexdigest(JSON.generate(cache_flags ? messages : CacheMarkers.strip_parts(messages)))
419
421
  rescue JSON::GeneratorError, Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError => e
420
422
  raise WorkflowError,
421
423
  "seed_messages content must be valid UTF-8 (Smith hashes via JSON.generate " \
data/lib/smith.rb CHANGED
@@ -227,6 +227,7 @@ require_relative "smith/budget/ledger"
227
227
  require_relative "smith/budget/host_ledger"
228
228
  require_relative "smith/budget/request_hold"
229
229
  require_relative "smith/budget/continuation"
230
+ require_relative "smith/cache_markers"
230
231
  require_relative "smith/response_memo"
231
232
 
232
233
  # Pricing (depends on Smith config)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: smith-agents
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.2
4
+ version: 0.12.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Ralak
@@ -219,6 +219,7 @@ files:
219
219
  - lib/smith/budget/reservation.rb
220
220
  - lib/smith/budget/reservation_contract.rb
221
221
  - lib/smith/budget/spent_reservation.rb
222
+ - lib/smith/cache_markers.rb
222
223
  - lib/smith/child_run.rb
223
224
  - lib/smith/child_runs_failed.rb
224
225
  - lib/smith/cli.rb