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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +215 -0
- data/README.md +367 -3
- data/lib/generators/mistri/install/install_generator.rb +54 -0
- data/lib/generators/mistri/install/templates/migration.rb.tt +14 -0
- data/lib/generators/mistri/install/templates/model.rb.tt +4 -0
- data/lib/generators/mistri/mcp/mcp_generator.rb +57 -0
- data/lib/generators/mistri/mcp/templates/migration.rb.tt +27 -0
- data/lib/generators/mistri/mcp/templates/model.rb.tt +63 -0
- data/lib/mistri/abort_signal.rb +63 -0
- data/lib/mistri/agent.rb +389 -0
- data/lib/mistri/budget.rb +29 -0
- data/lib/mistri/compaction.rb +78 -0
- data/lib/mistri/compactor.rb +182 -0
- data/lib/mistri/content.rb +89 -0
- data/lib/mistri/edit.rb +238 -0
- data/lib/mistri/errors.rb +94 -0
- data/lib/mistri/event.rb +54 -0
- data/lib/mistri/mcp/client.rb +156 -0
- data/lib/mistri/mcp/oauth.rb +286 -0
- data/lib/mistri/mcp/wires.rb +164 -0
- data/lib/mistri/mcp.rb +96 -0
- data/lib/mistri/memory.rb +26 -0
- data/lib/mistri/message.rb +90 -0
- data/lib/mistri/models.rb +43 -0
- data/lib/mistri/partial_json.rb +210 -0
- data/lib/mistri/providers/anthropic/assembler.rb +205 -0
- data/lib/mistri/providers/anthropic/serializer.rb +106 -0
- data/lib/mistri/providers/anthropic.rb +106 -0
- data/lib/mistri/providers/fake.rb +109 -0
- data/lib/mistri/providers/gemini/assembler.rb +163 -0
- data/lib/mistri/providers/gemini/serializer.rb +109 -0
- data/lib/mistri/providers/gemini.rb +73 -0
- data/lib/mistri/providers/openai/assembler.rb +205 -0
- data/lib/mistri/providers/openai/serializer.rb +104 -0
- data/lib/mistri/providers/openai.rb +72 -0
- data/lib/mistri/reminder.rb +36 -0
- data/lib/mistri/result.rb +32 -0
- data/lib/mistri/retry_policy.rb +47 -0
- data/lib/mistri/schema.rb +162 -0
- data/lib/mistri/session.rb +124 -0
- data/lib/mistri/sinks/action_cable.rb +30 -0
- data/lib/mistri/sinks/coalesced.rb +61 -0
- data/lib/mistri/sinks/sse.rb +26 -0
- data/lib/mistri/skill.rb +15 -0
- data/lib/mistri/skills.rb +81 -0
- data/lib/mistri/sse.rb +50 -0
- data/lib/mistri/stop_reason.rb +25 -0
- data/lib/mistri/stores/active_record.rb +47 -0
- data/lib/mistri/stores/jsonl.rb +37 -0
- data/lib/mistri/stores/memory.rb +22 -0
- data/lib/mistri/sub_agent.rb +211 -0
- data/lib/mistri/tool.rb +95 -0
- data/lib/mistri/tool_call.rb +18 -0
- data/lib/mistri/tool_context.rb +15 -0
- data/lib/mistri/tool_executor.rb +87 -0
- data/lib/mistri/tool_result.rb +23 -0
- data/lib/mistri/tools/edit_file.rb +37 -0
- data/lib/mistri/tools/find_in_file.rb +36 -0
- data/lib/mistri/tools/list_files.rb +16 -0
- data/lib/mistri/tools/read_file.rb +38 -0
- data/lib/mistri/tools/read_memory.rb +16 -0
- data/lib/mistri/tools/update_memory.rb +22 -0
- data/lib/mistri/tools/write_file.rb +20 -0
- data/lib/mistri/tools.rb +50 -0
- data/lib/mistri/transport.rb +228 -0
- data/lib/mistri/usage.rb +79 -0
- data/lib/mistri/version.rb +1 -1
- data/lib/mistri/workspace/active_record.rb +47 -0
- data/lib/mistri/workspace/directory.rb +52 -0
- data/lib/mistri/workspace/memory.rb +40 -0
- data/lib/mistri/workspace/single.rb +48 -0
- data/lib/mistri.rb +89 -0
- 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
|