mistri 0.0.3 → 0.2.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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +215 -0
  3. data/README.md +367 -3
  4. data/lib/generators/mistri/install/install_generator.rb +54 -0
  5. data/lib/generators/mistri/install/templates/migration.rb.tt +14 -0
  6. data/lib/generators/mistri/install/templates/model.rb.tt +4 -0
  7. data/lib/generators/mistri/mcp/mcp_generator.rb +57 -0
  8. data/lib/generators/mistri/mcp/templates/migration.rb.tt +27 -0
  9. data/lib/generators/mistri/mcp/templates/model.rb.tt +63 -0
  10. data/lib/mistri/abort_signal.rb +63 -0
  11. data/lib/mistri/agent.rb +389 -0
  12. data/lib/mistri/budget.rb +29 -0
  13. data/lib/mistri/compaction.rb +78 -0
  14. data/lib/mistri/compactor.rb +182 -0
  15. data/lib/mistri/content.rb +89 -0
  16. data/lib/mistri/edit.rb +238 -0
  17. data/lib/mistri/errors.rb +94 -0
  18. data/lib/mistri/event.rb +54 -0
  19. data/lib/mistri/mcp/client.rb +156 -0
  20. data/lib/mistri/mcp/oauth.rb +286 -0
  21. data/lib/mistri/mcp/wires.rb +164 -0
  22. data/lib/mistri/mcp.rb +96 -0
  23. data/lib/mistri/memory.rb +26 -0
  24. data/lib/mistri/message.rb +90 -0
  25. data/lib/mistri/models.rb +43 -0
  26. data/lib/mistri/partial_json.rb +210 -0
  27. data/lib/mistri/providers/anthropic/assembler.rb +205 -0
  28. data/lib/mistri/providers/anthropic/serializer.rb +106 -0
  29. data/lib/mistri/providers/anthropic.rb +106 -0
  30. data/lib/mistri/providers/fake.rb +109 -0
  31. data/lib/mistri/providers/gemini/assembler.rb +163 -0
  32. data/lib/mistri/providers/gemini/serializer.rb +109 -0
  33. data/lib/mistri/providers/gemini.rb +73 -0
  34. data/lib/mistri/providers/openai/assembler.rb +205 -0
  35. data/lib/mistri/providers/openai/serializer.rb +104 -0
  36. data/lib/mistri/providers/openai.rb +72 -0
  37. data/lib/mistri/reminder.rb +36 -0
  38. data/lib/mistri/result.rb +32 -0
  39. data/lib/mistri/retry_policy.rb +47 -0
  40. data/lib/mistri/schema.rb +162 -0
  41. data/lib/mistri/session.rb +124 -0
  42. data/lib/mistri/sinks/action_cable.rb +30 -0
  43. data/lib/mistri/sinks/coalesced.rb +61 -0
  44. data/lib/mistri/sinks/sse.rb +26 -0
  45. data/lib/mistri/skill.rb +15 -0
  46. data/lib/mistri/skills.rb +81 -0
  47. data/lib/mistri/sse.rb +50 -0
  48. data/lib/mistri/stop_reason.rb +25 -0
  49. data/lib/mistri/stores/active_record.rb +47 -0
  50. data/lib/mistri/stores/jsonl.rb +37 -0
  51. data/lib/mistri/stores/memory.rb +22 -0
  52. data/lib/mistri/sub_agent.rb +211 -0
  53. data/lib/mistri/tool.rb +95 -0
  54. data/lib/mistri/tool_call.rb +18 -0
  55. data/lib/mistri/tool_context.rb +15 -0
  56. data/lib/mistri/tool_executor.rb +87 -0
  57. data/lib/mistri/tool_result.rb +23 -0
  58. data/lib/mistri/tools/edit_file.rb +37 -0
  59. data/lib/mistri/tools/find_in_file.rb +36 -0
  60. data/lib/mistri/tools/list_files.rb +16 -0
  61. data/lib/mistri/tools/read_file.rb +38 -0
  62. data/lib/mistri/tools/read_memory.rb +16 -0
  63. data/lib/mistri/tools/update_memory.rb +22 -0
  64. data/lib/mistri/tools/write_file.rb +20 -0
  65. data/lib/mistri/tools.rb +50 -0
  66. data/lib/mistri/transport.rb +228 -0
  67. data/lib/mistri/usage.rb +79 -0
  68. data/lib/mistri/version.rb +1 -1
  69. data/lib/mistri/workspace/active_record.rb +47 -0
  70. data/lib/mistri/workspace/directory.rb +52 -0
  71. data/lib/mistri/workspace/memory.rb +40 -0
  72. data/lib/mistri/workspace/single.rb +48 -0
  73. data/lib/mistri.rb +89 -0
  74. metadata +79 -10
data/lib/mistri/mcp.rb ADDED
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Mistri
6
+ # Bridge Model Context Protocol servers into Mistri tools: list a server's
7
+ # tools, hand them to an agent, and everything the harness already does
8
+ # composes — approval gates on third-party write tools, retries, sub-agent
9
+ # pools, the ui channel.
10
+ #
11
+ # client = Mistri::MCP::Client.new(url: "https://mcp.linear.app/mcp",
12
+ # token: -> { connection.fresh_token })
13
+ # agent = Mistri.agent("claude-opus-4-8",
14
+ # tools: Mistri::MCP.tools(client, prefix: "linear"))
15
+ #
16
+ # The bridge is duck-typed: any client responding to tools (an array of
17
+ # {"name", "description", "inputSchema"} hashes) and call_tool(name, args)
18
+ # bridges the same way, so the official mcp gem's client plugs in too.
19
+ module MCP
20
+ # A protocol-level failure: a JSON-RPC error, a missing response, an
21
+ # unsupported negotiation.
22
+ class Error < Mistri::Error
23
+ attr_reader :code
24
+
25
+ def initialize(message = nil, code: nil)
26
+ @code = code
27
+ super(message)
28
+ end
29
+ end
30
+
31
+ # The server expired this client's session (a 404 with a session
32
+ # attached); the spec says start a fresh one, and Client does.
33
+ class SessionExpired < Error; end
34
+
35
+ module_function
36
+
37
+ # The server's tools as Mistri tools. allow/deny filter by remote name,
38
+ # prefix namespaces local names ("linear__create_issue") against
39
+ # collisions, and gates marks tools needing human approval
40
+ # (gates: { "create_issue" => true }, or needs_approval: for all).
41
+ def tools(client, allow: nil, deny: [], prefix: nil, needs_approval: false, gates: {})
42
+ listed = client.tools
43
+ listed = listed.select { |tool| allow.include?(tool["name"]) } if allow
44
+ listed = listed.reject { |tool| deny.include?(tool["name"]) }
45
+ listed.map do |tool|
46
+ bridge(client, tool, prefix: prefix, gate: gates.fetch(tool["name"], needs_approval))
47
+ end
48
+ end
49
+
50
+ def bridge(client, spec, prefix: nil, gate: false)
51
+ remote = spec.fetch("name")
52
+ local = prefix ? "#{prefix}__#{remote}" : remote
53
+ Tool.define(local, spec["description"].to_s,
54
+ input_schema: spec["inputSchema"] || Tool::EMPTY_SCHEMA,
55
+ needs_approval: gate) do |args|
56
+ answer(client.call_tool(remote, args || {}))
57
+ end
58
+ end
59
+
60
+ # An MCP result becomes model-readable content: text joins, images ride
61
+ # as image blocks, and isError answers in band so the model can react.
62
+ def answer(result)
63
+ blocks = Array(result["content"]).map { |block| convert(block) }
64
+ if result["isError"]
65
+ text = blocks.grep(String).join("\n")
66
+ return "MCP tool error: #{text.empty? ? "unknown error" : text}"
67
+ end
68
+ if blocks.empty? && result["structuredContent"]
69
+ return JSON.generate(result["structuredContent"])
70
+ end
71
+ return blocks.join("\n") if blocks.all?(String)
72
+
73
+ blocks
74
+ end
75
+
76
+ def convert(block)
77
+ case block["type"]
78
+ when "text" then block["text"].to_s
79
+ when "image"
80
+ Content::Image.from_bytes(block["data"].to_s.unpack1("m"),
81
+ mime_type: block["mimeType"] || "image/png")
82
+ when "resource" then resource_text(block["resource"] || {})
83
+ when "resource_link" then "[resource: #{block["uri"]}]"
84
+ else "[unsupported #{block["type"]} content]"
85
+ end
86
+ end
87
+
88
+ def resource_text(resource)
89
+ resource["text"] || "[resource: #{resource["uri"]}]"
90
+ end
91
+ end
92
+ end
93
+
94
+ require_relative "mcp/wires"
95
+ require_relative "mcp/client"
96
+ require_relative "mcp/oauth"
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ # Durable knowledge that outlives a session, living wherever the host says:
5
+ # an org's row, a user record, a file. What memory means (per org, per
6
+ # user, per project) is the host's call; Mistri only reads and replaces it.
7
+ #
8
+ # memory = Mistri::Memory.new(
9
+ # read: -> { org.agent_memory.to_s },
10
+ # write: ->(text) { org.update!(agent_memory: text) }
11
+ # )
12
+ # agent = Mistri.agent("claude-opus-4-8", tools: [*Mistri::Tools.memory(memory)])
13
+ class Memory
14
+ def initialize(read:, write:)
15
+ @read = read
16
+ @write = write
17
+ end
18
+
19
+ def read = @read.call.to_s
20
+
21
+ def replace(content)
22
+ @write.call(content.to_s)
23
+ nil
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ # One message in a conversation, the single shape every provider translates to
5
+ # and from its wire format. Immutable: streaming builds snapshots, sessions
6
+ # replay values, and nothing aliases across threads.
7
+ #
8
+ # Roles: :system, :user, :assistant, :tool (a tool result linked back by
9
+ # tool_call_id). Assistant messages carry the model and provider that produced
10
+ # them, which is what lets a later turn replay history across models, plus
11
+ # usage and the stop reason.
12
+ #
13
+ # ui is a tool result's host-only channel: it persists with the message and
14
+ # rides its :tool_result event, but no serializer ever sends it to a model.
15
+ # error is the machine-readable failure on an errored turn (ErrorData
16
+ # shape); error_message stays the human story.
17
+ class Message < Data.define(:role, :content, :tool_call_id, :tool_name,
18
+ :model, :provider, :usage, :stop_reason, :error_message, :ui,
19
+ :error)
20
+ ROLES = %i[system user assistant tool].freeze
21
+
22
+ def initialize(role:, content: nil, tool_call_id: nil, tool_name: nil, model: nil,
23
+ provider: nil, usage: nil, stop_reason: nil, error_message: nil, ui: nil,
24
+ error: nil)
25
+ role = role.to_sym
26
+ raise ArgumentError, "unknown role #{role.inspect}" unless ROLES.include?(role)
27
+ if stop_reason && !StopReason.valid?(stop_reason)
28
+ raise ArgumentError, "unknown stop reason #{stop_reason.inspect}"
29
+ end
30
+
31
+ super(role:, content: Content.wrap(content).freeze, tool_call_id:, tool_name:,
32
+ model:, provider:, usage:, stop_reason:, error_message:, ui:, error:)
33
+ end
34
+
35
+ def self.system(content) = new(role: :system, content:)
36
+
37
+ def self.user(content) = new(role: :user, content:)
38
+
39
+ # A user turn carrying images alongside optional text.
40
+ def self.user_with_images(content, images = [])
41
+ images = Array(images)
42
+ return user(content) if images.empty?
43
+
44
+ text = content.to_s
45
+ blocks = text.empty? ? images : [Content::Text.new(text:), *images]
46
+ new(role: :user, content: blocks)
47
+ end
48
+
49
+ def self.assistant(content: nil, tool_calls: [], **meta)
50
+ new(role: :assistant, content: [*Content.wrap(content), *tool_calls], **meta)
51
+ end
52
+
53
+ def self.tool(content:, tool_call_id:, tool_name: nil, ui: nil)
54
+ new(role: :tool, content:, tool_call_id:, tool_name:, ui:)
55
+ end
56
+
57
+ def self.from_h(hash)
58
+ h = hash.transform_keys(&:to_s)
59
+ new(role: h.fetch("role").to_sym,
60
+ content: Array(h["content"]).map { |block| Content.from_h(block) },
61
+ tool_call_id: h["tool_call_id"], tool_name: h["tool_name"],
62
+ model: h["model"], provider: h["provider"]&.to_sym,
63
+ usage: h["usage"] && Usage.from_h(h["usage"]),
64
+ stop_reason: h["stop_reason"]&.to_sym, error_message: h["error_message"],
65
+ ui: h["ui"], error: h["error"])
66
+ end
67
+
68
+ def system? = role == :system
69
+ def user? = role == :user
70
+ def assistant? = role == :assistant
71
+ def tool? = role == :tool
72
+
73
+ # Every Text block joined, or nil when the turn carried no text.
74
+ def text
75
+ texts = content.grep(Content::Text)
76
+ texts.empty? ? nil : texts.map(&:text).join
77
+ end
78
+
79
+ def tool_calls = content.grep(ToolCall)
80
+
81
+ def tool_calls? = content.any?(ToolCall)
82
+
83
+ # A serialization shape, not the member hash: rebuild with .from_h,
84
+ # never with new(**to_h).
85
+ def to_h
86
+ { role:, content: content.map(&:to_h), tool_call_id:, tool_name:, model:,
87
+ provider:, usage: usage&.to_h, stop_reason:, error_message:, ui:, error: }.compact
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ # The model catalog: capability data for known models, with graceful
5
+ # passthrough for unknown ones. An id missing here still works everywhere;
6
+ # the catalog only improves defaults (output ceilings, provider inference),
7
+ # so a brand-new model is usable the day it ships.
8
+ module Models
9
+ # thinking is how the model accepts a reasoning request: :adaptive (the
10
+ # model decides), :budget (a token budget), or :effort. It is what keeps
11
+ # a provider from sending an unsupported thinking shape that 400s.
12
+ Model = Data.define(:id, :provider, :max_output, :context_window, :thinking)
13
+
14
+ CATALOG = [
15
+ ["claude-fable-5", :anthropic, 128_000, 200_000, :adaptive],
16
+ ["claude-opus-4-8", :anthropic, 128_000, 200_000, :adaptive],
17
+ ["claude-opus-4-7", :anthropic, 128_000, 200_000, :adaptive],
18
+ ["claude-opus-4-6", :anthropic, 128_000, 200_000, :adaptive],
19
+ ["claude-sonnet-5", :anthropic, 128_000, 200_000, :adaptive],
20
+ ["claude-sonnet-4-6", :anthropic, 128_000, 200_000, :adaptive],
21
+ ["claude-haiku-4-5", :anthropic, 64_000, 200_000, :budget],
22
+ ["gpt-5.5", :openai, 128_000, 400_000, :effort],
23
+ ["gpt-5.4", :openai, 128_000, 400_000, :effort],
24
+ ["gpt-5-nano", :openai, 128_000, 400_000, :effort],
25
+ ["gemini-3.5-flash", :gemini, 65_536, 1_048_576, :level],
26
+ ["gemini-3.1-pro-preview", :gemini, 65_536, 1_048_576, :level],
27
+ ["gemini-2.5-pro", :gemini, 65_536, 1_048_576, :budget],
28
+ ["gemini-2.5-flash", :gemini, 65_536, 1_048_576, :budget]
29
+ ].to_h do |id, provider, max_output, context_window, thinking|
30
+ [id, Model.new(id:, provider:, max_output:, context_window:, thinking:)]
31
+ end.freeze
32
+
33
+ # Dated aliases resolve to their base entry: claude-opus-4-8-20260115 and
34
+ # gpt-5.4-2025-04-14 both match their base ids.
35
+ def self.find(id)
36
+ CATALOG[id] || CATALOG[id.to_s.sub(/-\d{8}\z/, "").sub(/-\d{4}-\d{2}-\d{2}\z/, "")]
37
+ end
38
+
39
+ def self.max_output(id) = find(id)&.max_output
40
+
41
+ def self.thinking(id) = find(id)&.thinking
42
+ end
43
+ end
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Mistri
6
+ # Parses the JSON prefix a model has emitted so far, so in-flight tool-call
7
+ # arguments are readable before the closing brace arrives. Best effort by
8
+ # contract: never raises, drops a dangling key or half-written token, and
9
+ # returns {} for hopeless input.
10
+ module PartialJson
11
+ def self.parse(text)
12
+ s = text.to_s.strip
13
+ return {} if s.empty?
14
+
15
+ value = Parser.new(s).parse
16
+ value.equal?(Parser::NOTHING) ? {} : value
17
+ rescue StandardError, SystemStackError
18
+ {}
19
+ end
20
+
21
+ # Recursive descent over the prefix. Truncation trips @partial, and every
22
+ # frame unwinds keeping the structure built so far.
23
+ class Parser
24
+ NOTHING = Object.new
25
+ LITERALS = { "true" => true, "false" => false, "null" => nil }.freeze
26
+
27
+ # Nesting past this is treated as truncated: a model's real tool
28
+ # arguments never nest this deep, and the cap keeps a pathological input
29
+ # from overflowing the stack.
30
+ MAX_DEPTH = 256
31
+
32
+ def initialize(source)
33
+ @s = source
34
+ @n = source.length
35
+ @i = 0
36
+ @partial = false
37
+ @depth = 0
38
+ end
39
+
40
+ def parse = value
41
+
42
+ private
43
+
44
+ def value
45
+ skip_ws
46
+ return truncated if eof?
47
+
48
+ case @s[@i]
49
+ when '"' then string
50
+ when "{" then nested { object }
51
+ when "[" then nested { array }
52
+ else scalar
53
+ end
54
+ end
55
+
56
+ def nested
57
+ return truncated if @depth >= MAX_DEPTH
58
+
59
+ @depth += 1
60
+ begin
61
+ yield
62
+ ensure
63
+ @depth -= 1
64
+ end
65
+ end
66
+
67
+ def object
68
+ @i += 1
69
+ obj = {}
70
+ until @partial
71
+ skip_ws
72
+ break truncated if eof?
73
+ break @i += 1 if @s[@i] == "}"
74
+ break unless @s[@i] == '"'
75
+
76
+ key, val = pair
77
+ obj[key] = val unless key.equal?(NOTHING) || val.equal?(NOTHING)
78
+ skip_ws
79
+ @i += 1 if !eof? && @s[@i] == ","
80
+ end
81
+ obj
82
+ end
83
+
84
+ # One key/value. A truncation before the value completes the key's
85
+ # last-known state: mid-key or mid-separator drops the pair entirely.
86
+ def pair
87
+ key = string
88
+ return [NOTHING, NOTHING] if @partial
89
+
90
+ skip_ws
91
+ return [NOTHING, truncated] if eof?
92
+ return [NOTHING, NOTHING] unless @s[@i] == ":"
93
+
94
+ @i += 1
95
+ [key, value]
96
+ end
97
+
98
+ def array
99
+ @i += 1
100
+ arr = []
101
+ until @partial
102
+ skip_ws
103
+ break truncated if eof?
104
+ break @i += 1 if @s[@i] == "]"
105
+
106
+ element = value
107
+ arr << element unless element.equal?(NOTHING)
108
+ skip_ws
109
+ @i += 1 if !eof? && @s[@i] == ","
110
+ end
111
+ arr
112
+ end
113
+
114
+ def string
115
+ start = @i
116
+ @i += 1
117
+ escaped = false
118
+ while @i < @n
119
+ case
120
+ when escaped then escaped = false
121
+ when @s[@i] == "\\" then escaped = true
122
+ when @s[@i] == '"'
123
+ @i += 1
124
+ return decode(@s[start...@i])
125
+ end
126
+ @i += 1
127
+ end
128
+ truncated
129
+ salvage_string(@s[start..])
130
+ end
131
+
132
+ # Close an unterminated string, first shedding a half-written escape: a
133
+ # partial \uXXXX, or a lone trailing backslash. A backslash is only
134
+ # dangling when the trailing run of them is odd; an even run is complete
135
+ # escaped backslashes and must be kept.
136
+ def salvage_string(fragment)
137
+ candidate = fragment.sub(/\\u[0-9a-fA-F]{0,3}\z/, "")
138
+ trailing = candidate[/\\+\z/]
139
+ candidate = candidate[0..-2] if trailing&.length&.odd?
140
+ decode(%(#{candidate}"))
141
+ end
142
+
143
+ def scalar
144
+ start = @i
145
+ @i += 1 while @i < @n && !"},] \n\r\t".include?(@s[@i])
146
+ token = @s[start...@i]
147
+ # A structural character in value position: consume it so the caller's
148
+ # loop always makes progress.
149
+ return (@i += 1) && NOTHING if token.empty?
150
+
151
+ truncated if eof?
152
+ literal(token) { number(token) }
153
+ end
154
+
155
+ def literal(token)
156
+ return LITERALS[token] if LITERALS.key?(token)
157
+
158
+ if @partial
159
+ match = LITERALS.keys.find { |word| word.start_with?(token) }
160
+ return LITERALS[match] if match
161
+ end
162
+ yield
163
+ end
164
+
165
+ def number(token)
166
+ Integer(token)
167
+ rescue ArgumentError
168
+ begin
169
+ finite(Float(token))
170
+ rescue ArgumentError
171
+ trimmed_number(token)
172
+ end
173
+ end
174
+
175
+ # A number cut mid-token: shed the dangling exponent, decimal point, or
176
+ # bare minus and retry.
177
+ def trimmed_number(token)
178
+ trimmed = token.sub(/[eE][+-]?\z/, "").sub(/\.\z/, "")
179
+ return NOTHING if trimmed.empty? || trimmed == "-"
180
+
181
+ finite(Float(trimmed))
182
+ rescue ArgumentError
183
+ NOTHING
184
+ end
185
+
186
+ # A model that emits 1e999 yields Float::INFINITY, which JSON cannot
187
+ # generate, so it would crash replay and persistence. Drop it.
188
+ def finite(number)
189
+ number.finite? ? number : NOTHING
190
+ end
191
+
192
+ def decode(json_string)
193
+ JSON.parse(json_string)
194
+ rescue JSON::ParserError
195
+ NOTHING
196
+ end
197
+
198
+ def skip_ws
199
+ @i += 1 while @i < @n && " \n\r\t".include?(@s[@i])
200
+ end
201
+
202
+ def eof? = @i >= @n
203
+
204
+ def truncated
205
+ @partial = true
206
+ NOTHING
207
+ end
208
+ end
209
+ end
210
+ end
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ module Providers
5
+ class Anthropic
6
+ # Folds the Messages API stream into the event union, building the
7
+ # assistant message block by block. Every emitted event carries an
8
+ # immutable snapshot of the message so far; in-flight tool arguments
9
+ # parse via PartialJson so consumers can read them mid-stream.
10
+ #
11
+ # Unknown event and block types are skipped by contract: the API adds
12
+ # types over time and a live stream must survive them.
13
+ class Assembler
14
+ def initialize(model:)
15
+ @model = model
16
+ @blocks = []
17
+ @current = nil
18
+ @usage = Usage.zero
19
+ @stop_reason = nil
20
+ @done = false
21
+ end
22
+
23
+ def feed(record, &)
24
+ case record["type"]
25
+ when "message_start" then @usage = parse_usage(record.dig("message", "usage"))
26
+ when "content_block_start" then start_block(record, &)
27
+ when "content_block_delta" then delta_block(record, &)
28
+ when "content_block_stop" then stop_block(record, &)
29
+ when "message_delta" then message_delta(record)
30
+ when "message_stop" then @done = true
31
+ when "error" then @error = wire_error(record["error"])
32
+ end
33
+ end
34
+
35
+ # Close the stream: the terminal event reflects how it ended. A stream
36
+ # that ended without message_stop was truncated (a dropped proxy, say),
37
+ # not user-aborted, so it fails for the loop to retry rather than
38
+ # reading as a cancellation.
39
+ def finish(&emit)
40
+ return fail_stream(@error, &emit) if @error
41
+ return fail_stream("stream ended without message_stop", &emit) unless @done
42
+
43
+ @message = assemble(stop_reason: @stop_reason || StopReason::STOP)
44
+ emit&.call(Event.new(type: :done, reason: @message.stop_reason, message: @message))
45
+ @message
46
+ end
47
+
48
+ def abort(&emit)
49
+ finalize_current
50
+ @message = assemble(stop_reason: StopReason::ABORTED, error_message: "aborted")
51
+ emit&.call(Event.new(type: :error, reason: StopReason::ABORTED, message: @message,
52
+ error_message: "aborted"))
53
+ @message
54
+ end
55
+
56
+ # In-stream failures carry a wire type; overloaded ones must classify
57
+ # as retryable, not fold into prose.
58
+ def wire_error(payload)
59
+ message = payload&.dig("message") || "provider error"
60
+ klass = payload&.dig("type").to_s.include?("overloaded") ? OverloadedError : ProviderError
61
+ klass.new(message)
62
+ end
63
+
64
+ def fail_stream(reason, &emit)
65
+ finalize_current
66
+ text = case reason
67
+ when ProviderError then "#{reason.class}: #{reason.describe}"
68
+ when Exception then "#{reason.class}: #{reason.message}"
69
+ else reason.to_s
70
+ end
71
+ @message = assemble(stop_reason: StopReason::ERROR, error_message: text,
72
+ error: ErrorData.for(reason))
73
+ emit&.call(Event.new(type: :error, reason: StopReason::ERROR, message: @message,
74
+ error_message: text))
75
+ @message
76
+ end
77
+
78
+ def message = @message ||= finish
79
+
80
+ Builder = Struct.new(:kind, :index, :text, :json, :signature, :id, :name, :redacted)
81
+
82
+ private
83
+
84
+ def start_block(record, &)
85
+ block = record["content_block"] || {}
86
+ kind = { "text" => :text, "thinking" => :thinking, "redacted_thinking" => :thinking,
87
+ "tool_use" => :toolcall }[block["type"]]
88
+ return unless kind
89
+
90
+ @current = Builder.new(kind, @blocks.size, +"", +"", nil,
91
+ block["id"], block["name"], block["type"] == "redacted_thinking")
92
+ @current.signature = block["data"] if @current.redacted
93
+ emit_event(:"#{kind}_start", content_index: @current.index, &)
94
+ end
95
+
96
+ def delta_block(record, &)
97
+ return unless @current
98
+
99
+ delta = record["delta"] || {}
100
+ case delta["type"]
101
+ when "text_delta" then text_delta(delta["text"], &)
102
+ when "thinking_delta" then thinking_delta(delta["thinking"], &)
103
+ when "signature_delta"
104
+ @current.signature = "#{@current.signature}#{delta["signature"]}"
105
+ when "input_json_delta" then input_delta(delta["partial_json"], &)
106
+ end
107
+ end
108
+
109
+ def text_delta(text, &)
110
+ @current.text << text.to_s
111
+ emit_event(:text_delta, content_index: @current.index, delta: text, &)
112
+ end
113
+
114
+ def thinking_delta(text, &)
115
+ @current.text << text.to_s
116
+ emit_event(:thinking_delta, content_index: @current.index, delta: text, &)
117
+ end
118
+
119
+ def input_delta(fragment, &)
120
+ @current.json << fragment.to_s
121
+ emit_event(:toolcall_delta, content_index: @current.index, delta: fragment, &)
122
+ end
123
+
124
+ def stop_block(_record, &)
125
+ return unless @current
126
+
127
+ block = finalize_current
128
+ kind = block.is_a?(ToolCall) ? :toolcall : block.type
129
+ fields = { content_index: @blocks.size - 1 }
130
+ fields[:tool_call] = block if block.is_a?(ToolCall)
131
+ fields[:content] = @blocks.last.is_a?(ToolCall) ? nil : builder_text(block)
132
+ emit_event(:"#{kind}_end", **fields.compact, &)
133
+ end
134
+
135
+ def message_delta(record)
136
+ reason = record.dig("delta", "stop_reason")
137
+ @stop_reason = map_stop_reason(reason) if reason
138
+ # message_delta usage is cumulative; merge output counts over the
139
+ # opening snapshot rather than summing.
140
+ output = record.dig("usage", "output_tokens")
141
+ @usage = @usage.with(output: output.to_i) if output
142
+ end
143
+
144
+ def finalize_current
145
+ return unless @current
146
+
147
+ built = build_block(@current)
148
+ @blocks << built
149
+ @current = nil
150
+ built
151
+ end
152
+
153
+ def build_block(builder)
154
+ case builder.kind
155
+ when :text then Content::Text.new(text: builder.text)
156
+ when :thinking
157
+ Content::Thinking.new(thinking: builder.text, signature: builder.signature,
158
+ redacted: builder.redacted)
159
+ when :toolcall
160
+ ToolCall.new(id: builder.id, name: builder.name,
161
+ arguments: parsed_arguments(builder.json), signature: nil)
162
+ end
163
+ end
164
+
165
+ def parsed_arguments(json)
166
+ parsed = json.strip.empty? ? {} : PartialJson.parse(json)
167
+ parsed.is_a?(Hash) ? parsed : {}
168
+ end
169
+
170
+ def builder_text(block)
171
+ block.respond_to?(:text) ? block.text : block.thinking
172
+ end
173
+
174
+ def emit_event(type, **fields, &emit)
175
+ emit&.call(Event.new(type:, partial: assemble, **fields))
176
+ end
177
+
178
+ def assemble(**meta)
179
+ blocks = @blocks.dup
180
+ blocks << build_block(@current) if @current
181
+ Message.assistant(content: blocks, model: @model, provider: :anthropic,
182
+ usage: @usage, **meta)
183
+ end
184
+
185
+ # pause_turn (a server tool paused a long turn) maps to tool_use so the
186
+ # loop continues the turn rather than ending it.
187
+ def map_stop_reason(reason)
188
+ { "end_turn" => StopReason::STOP, "stop_sequence" => StopReason::STOP,
189
+ "max_tokens" => StopReason::LENGTH, "tool_use" => StopReason::TOOL_USE,
190
+ "pause_turn" => StopReason::TOOL_USE }.fetch(reason, StopReason::STOP)
191
+ end
192
+
193
+ def parse_usage(raw)
194
+ return Usage.zero unless raw
195
+
196
+ cache_creation = raw["cache_creation"] || {}
197
+ Usage.new(input: raw["input_tokens"].to_i, output: raw["output_tokens"].to_i,
198
+ cache_read: raw["cache_read_input_tokens"].to_i,
199
+ cache_write: raw["cache_creation_input_tokens"].to_i,
200
+ cache_write_1h: cache_creation["ephemeral_1h_input_tokens"].to_i)
201
+ end
202
+ end
203
+ end
204
+ end
205
+ end