mistri 0.0.2 → 0.1.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 +177 -0
- data/NOTICE +9 -0
- data/README.md +314 -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/mistri/abort_signal.rb +63 -0
- data/lib/mistri/agent.rb +340 -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 +50 -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/result.rb +30 -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 +94 -0
- data/lib/mistri/tool_call.rb +18 -0
- data/lib/mistri/tool_context.rb +15 -0
- data/lib/mistri/tool_executor.rb +66 -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 +187 -0
- data/lib/mistri/usage.rb +79 -0
- data/lib/mistri/version.rb +3 -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 +91 -2
- metadata +73 -7
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
# A two-channel tool result: content goes to the model, ui goes only to the
|
|
5
|
+
# host. The ui payload rides the tool message and its :tool_result event,
|
|
6
|
+
# persists with the session for transcript re-renders, and never reaches a
|
|
7
|
+
# provider. Return one from a handler when the UI needs more than the model
|
|
8
|
+
# should read or pay for: full query rows behind a compact answer, the
|
|
9
|
+
# updated document behind "saved".
|
|
10
|
+
#
|
|
11
|
+
# Tool.define("edit_page", "Edits the page.") do |args|
|
|
12
|
+
# page = apply(args)
|
|
13
|
+
# Mistri::ToolResult.new(content: "Updated.", ui: { "html" => page })
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# ui must be JSON-serializable; it is stored and delivered in canonical
|
|
17
|
+
# JSON form (string keys), the same shape a reloaded session reads.
|
|
18
|
+
ToolResult = Data.define(:content, :ui) do
|
|
19
|
+
def initialize(content:, ui: nil)
|
|
20
|
+
super
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
module Tools
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
# The model-facing shape is flat {path, old_string, new_string,
|
|
8
|
+
# replace_all} on purpose: it is the shape frontier models are trained on,
|
|
9
|
+
# and nested edit arrays measurably degrade their calls. Failures come
|
|
10
|
+
# back in band with the closest region and its exact difference, so the
|
|
11
|
+
# model's retry is one shot.
|
|
12
|
+
def edit_file(workspace)
|
|
13
|
+
Tool.define("edit_file",
|
|
14
|
+
"Replace an exact snippet of a document. Copy old_string verbatim from " \
|
|
15
|
+
"read_file output including whitespace, without line-number prefixes. " \
|
|
16
|
+
"It must match exactly one place; add surrounding lines to make it " \
|
|
17
|
+
"unique, or set replace_all to change every occurrence.",
|
|
18
|
+
eager_input_streaming: true,
|
|
19
|
+
schema: lambda {
|
|
20
|
+
string :path, "Document path", required: true
|
|
21
|
+
string :old_string, "Exact text to replace (whitespace matters)", required: true
|
|
22
|
+
string :new_string, "Replacement text", required: true
|
|
23
|
+
boolean :replace_all, "Replace every occurrence instead of exactly one"
|
|
24
|
+
}) do |args|
|
|
25
|
+
args = Tools.tolerate(args)
|
|
26
|
+
with_document(workspace, args) do |content|
|
|
27
|
+
result = Edit.replace(content, args["old_string"], args["new_string"],
|
|
28
|
+
replace_all: args["replace_all"] == true)
|
|
29
|
+
workspace.write(args["path"], result.content)
|
|
30
|
+
"Replaced #{result.count} occurrence(s) in #{args["path"]}"
|
|
31
|
+
end
|
|
32
|
+
rescue EditError => e
|
|
33
|
+
"edit_file failed: #{e.message}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
module Tools
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def find_in_file(workspace)
|
|
8
|
+
Tool.define("find_in_file",
|
|
9
|
+
"Find text in a document. Returns line-numbered matches with context, " \
|
|
10
|
+
"so you can locate a region without reading the whole document.",
|
|
11
|
+
schema: lambda {
|
|
12
|
+
string :path, "Document path", required: true
|
|
13
|
+
string :query, "Text to find (plain substring)", required: true
|
|
14
|
+
integer :context, "Context lines around each match"
|
|
15
|
+
}) do |args|
|
|
16
|
+
with_document(workspace, args) do |content|
|
|
17
|
+
Tools.find_matches(content, args["query"], (args["context"] || 2).to_i)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def find_matches(content, query, context)
|
|
23
|
+
lines = content.lines
|
|
24
|
+
hits = lines.each_index.select { |i| lines[i].include?(query) }
|
|
25
|
+
return "No matches for #{query.inspect}." if hits.empty?
|
|
26
|
+
|
|
27
|
+
blocks = hits.first(20).map do |hit|
|
|
28
|
+
from = [hit - context, 0].max
|
|
29
|
+
to = [hit + context, lines.length - 1].min
|
|
30
|
+
(from..to).map { |n| "#{n + 1}: #{lines[n]}" }.join
|
|
31
|
+
end
|
|
32
|
+
notice = hits.length > 20 ? "\n[#{hits.length - 20} more matches not shown]" : ""
|
|
33
|
+
"#{blocks.join("---\n")}#{notice}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
module Tools
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def list_files(workspace)
|
|
8
|
+
Tool.define("list_files",
|
|
9
|
+
"List document paths in the workspace, optionally under a prefix.",
|
|
10
|
+
schema: -> { string :prefix, "Only paths starting with this" }) do |args|
|
|
11
|
+
paths = workspace.list(args["prefix"])
|
|
12
|
+
paths.empty? ? "No documents found." : paths.join("\n")
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
module Tools
|
|
5
|
+
MAX_READ_CHARS = 20_000
|
|
6
|
+
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def read_file(workspace)
|
|
10
|
+
Tool.define("read_file",
|
|
11
|
+
"Read a document with line numbers. Use offset and limit for a window " \
|
|
12
|
+
"into a long document.",
|
|
13
|
+
schema: lambda {
|
|
14
|
+
string :path, "Document path", required: true
|
|
15
|
+
integer :offset, "First line to read (1-based)"
|
|
16
|
+
integer :limit, "How many lines to read"
|
|
17
|
+
}) do |args|
|
|
18
|
+
with_document(workspace, args) do |content|
|
|
19
|
+
Tools.numbered_window(content, args["offset"], args["limit"])
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def numbered_window(content, offset, limit)
|
|
25
|
+
lines = content.lines
|
|
26
|
+
from = [(offset || 1).to_i, 1].max
|
|
27
|
+
to = limit ? [from + limit.to_i - 1, lines.length].min : lines.length
|
|
28
|
+
numbered = (from..to).map { |n| "#{n}: #{lines[n - 1]}" }.join
|
|
29
|
+
windowed = to < lines.length || from > 1
|
|
30
|
+
suffix = windowed ? "\n[showing lines #{from}-#{to} of #{lines.length}]" : ""
|
|
31
|
+
return "#{numbered}#{suffix}" if numbered.length <= MAX_READ_CHARS
|
|
32
|
+
|
|
33
|
+
cut = numbered[0, MAX_READ_CHARS]
|
|
34
|
+
cut = cut[0..(cut.rindex("\n") || -1)]
|
|
35
|
+
"#{cut}\n[truncated at #{MAX_READ_CHARS} chars; use offset/limit to read more]"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
module Tools
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def read_memory(memory)
|
|
8
|
+
Tool.define("read_memory",
|
|
9
|
+
"Read the durable memory: knowledge kept across sessions. Check it " \
|
|
10
|
+
"before starting work that earlier sessions may have learned about.") do |_args|
|
|
11
|
+
content = memory.read
|
|
12
|
+
content.empty? ? "Memory is empty." : content
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
module Tools
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
# Whole-document replace on purpose: the model rewrites memory as one
|
|
8
|
+
# coherent text instead of appending fragments that drift.
|
|
9
|
+
def update_memory(memory)
|
|
10
|
+
Tool.define("update_memory",
|
|
11
|
+
"Replace the durable memory with an updated version. Pass the FULL " \
|
|
12
|
+
"text: what you were given plus what you learned, rewritten to stay " \
|
|
13
|
+
"short and current.",
|
|
14
|
+
schema: lambda {
|
|
15
|
+
string :content, "The complete new memory text", required: true
|
|
16
|
+
}) do |args|
|
|
17
|
+
memory.replace(args["content"])
|
|
18
|
+
"Memory updated (#{args["content"].to_s.length} chars)."
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
module Tools
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def write_file(workspace)
|
|
8
|
+
Tool.define("write_file",
|
|
9
|
+
"Create or fully overwrite a document with the given content.",
|
|
10
|
+
eager_input_streaming: true,
|
|
11
|
+
schema: lambda {
|
|
12
|
+
string :path, "Document path", required: true
|
|
13
|
+
string :content, "The full document content", required: true
|
|
14
|
+
}) do |args|
|
|
15
|
+
workspace.write(args["path"], args["content"])
|
|
16
|
+
"Wrote #{args["path"]} (#{args["content"].to_s.length} chars)"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
data/lib/mistri/tools.rb
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
# Built-in tools. Tools.files binds the document tools to a workspace, so
|
|
5
|
+
# "file" means whatever the workspace says it means: a database column, a
|
|
6
|
+
# row in a documents table, or an actual file. The names stay read_file and
|
|
7
|
+
# edit_file because those are the tool names models are trained on.
|
|
8
|
+
module Tools
|
|
9
|
+
ALIASES = { "oldText" => "old_string", "old" => "old_string", "search" => "old_string",
|
|
10
|
+
"newText" => "new_string", "new" => "new_string", "replace" => "new_string",
|
|
11
|
+
"replaceAll" => "replace_all", "file" => "path", "filename" => "path" }.freeze
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def files(workspace)
|
|
16
|
+
[read_file(workspace), write_file(workspace), edit_file(workspace),
|
|
17
|
+
find_in_file(workspace), list_files(workspace)]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def memory(store)
|
|
21
|
+
[read_memory(store), update_memory(store)]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def with_document(workspace, args)
|
|
25
|
+
content = workspace.read(args["path"])
|
|
26
|
+
return "No document at #{args["path"].inspect}. Use list_files to see paths." if content.nil?
|
|
27
|
+
|
|
28
|
+
yield content
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Absorb the drift real models produce: alias keys, stringly booleans,
|
|
32
|
+
# unknown keys dropped by simply never being read.
|
|
33
|
+
def tolerate(args)
|
|
34
|
+
normalized = args.to_h { |key, value| [ALIASES.fetch(key.to_s, key.to_s), value] }
|
|
35
|
+
case normalized["replace_all"]
|
|
36
|
+
when "true", "1", 1 then normalized["replace_all"] = true
|
|
37
|
+
when "false", "0", 0, nil then normalized["replace_all"] = false
|
|
38
|
+
end
|
|
39
|
+
normalized
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
require_relative "tools/read_file"
|
|
45
|
+
require_relative "tools/write_file"
|
|
46
|
+
require_relative "tools/edit_file"
|
|
47
|
+
require_relative "tools/find_in_file"
|
|
48
|
+
require_relative "tools/list_files"
|
|
49
|
+
require_relative "tools/read_memory"
|
|
50
|
+
require_relative "tools/update_memory"
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Mistri
|
|
8
|
+
# HTTP for one provider origin, held open across the turns of a run: a
|
|
9
|
+
# multi-turn agent pays the TCP and TLS handshake once, not per turn. Not
|
|
10
|
+
# shareable across threads; a mutex serializes accidental concurrent use onto
|
|
11
|
+
# the single socket, and re-entering from a streaming callback raises
|
|
12
|
+
# ThreadError.
|
|
13
|
+
#
|
|
14
|
+
# Streaming reads abort two ways: cooperatively between fragments, and hard,
|
|
15
|
+
# by closing the socket from the abort signal's callback, so a stalled read
|
|
16
|
+
# stops immediately instead of waiting out the read timeout.
|
|
17
|
+
class Transport
|
|
18
|
+
KEEP_ALIVE_SECONDS = 30
|
|
19
|
+
|
|
20
|
+
def initialize(origin:, open_timeout: 15, read_timeout: 300, write_timeout: 60)
|
|
21
|
+
@origin = origin.to_s.chomp("/")
|
|
22
|
+
@uri = URI(@origin)
|
|
23
|
+
@open_timeout = open_timeout
|
|
24
|
+
@read_timeout = read_timeout
|
|
25
|
+
@write_timeout = write_timeout
|
|
26
|
+
@mutex = Mutex.new
|
|
27
|
+
@connection = nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# POST and decode a JSON response body. Retries once on a dead idle
|
|
31
|
+
# socket, so it suits idempotent endpoints.
|
|
32
|
+
def post(path, body:, headers: {})
|
|
33
|
+
response = @mutex.synchronize do
|
|
34
|
+
with_retry { connection.request(build_request(path, body, headers)) }
|
|
35
|
+
end
|
|
36
|
+
raise_for_status(response)
|
|
37
|
+
JSON.parse(response.body)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# POST and stream the SSE response, yielding each decoded data record.
|
|
41
|
+
# Returns :aborted when the signal cancelled the stream, else nil.
|
|
42
|
+
def stream_post(path, body:, headers: {}, signal: nil, &block)
|
|
43
|
+
return :aborted if signal&.aborted?
|
|
44
|
+
|
|
45
|
+
@mutex.synchronize { stream_locked(path, body, headers, signal, &block) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def close
|
|
49
|
+
@mutex.synchronize { teardown }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def stream_locked(path, body, headers, signal, &block)
|
|
55
|
+
retried = false
|
|
56
|
+
begin
|
|
57
|
+
started = false
|
|
58
|
+
aborted = false
|
|
59
|
+
conn = connection
|
|
60
|
+
# The closer targets this turn's connection, so an abort that fires
|
|
61
|
+
# late, after the turn already finished, cannot touch a successor's
|
|
62
|
+
# socket.
|
|
63
|
+
closer = signal&.on_abort { quietly_finish(conn) }
|
|
64
|
+
begin
|
|
65
|
+
conn.request(build_request(path, body, headers, streaming: true)) do |response|
|
|
66
|
+
started = true
|
|
67
|
+
raise_for_status(response)
|
|
68
|
+
aborted = read_stream(response, signal, &block)
|
|
69
|
+
end
|
|
70
|
+
ensure
|
|
71
|
+
signal&.remove_callback(closer) if closer
|
|
72
|
+
end
|
|
73
|
+
# An abort mid-body leaves unread bytes on the socket; drop it rather
|
|
74
|
+
# than let the next request read stale frames.
|
|
75
|
+
teardown if aborted
|
|
76
|
+
aborted ? :aborted : nil
|
|
77
|
+
rescue IOError, SocketError, SystemCallError, Timeout::Error => e
|
|
78
|
+
teardown
|
|
79
|
+
return :aborted if signal&.aborted?
|
|
80
|
+
|
|
81
|
+
# A dead idle keep-alive socket fails before the response starts and
|
|
82
|
+
# retries safely. A drop after events flowed must not replay the turn.
|
|
83
|
+
if !started && !retried
|
|
84
|
+
retried = true
|
|
85
|
+
retry
|
|
86
|
+
end
|
|
87
|
+
raise ProviderError, "connection lost mid-stream: #{e.message}"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def read_stream(response, signal, &block)
|
|
92
|
+
sse = SSE.new
|
|
93
|
+
aborted = false
|
|
94
|
+
response.read_body do |fragment|
|
|
95
|
+
if signal&.aborted?
|
|
96
|
+
aborted = true
|
|
97
|
+
break
|
|
98
|
+
end
|
|
99
|
+
sse.feed(fragment, &block)
|
|
100
|
+
end
|
|
101
|
+
sse.finish(&block) unless aborted
|
|
102
|
+
aborted
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def build_request(path, body, headers, streaming: false)
|
|
106
|
+
request = Net::HTTP::Post.new(URI("#{@origin}#{path}"))
|
|
107
|
+
request["Content-Type"] = "application/json"
|
|
108
|
+
if streaming
|
|
109
|
+
request["Accept"] = "text/event-stream"
|
|
110
|
+
# Net::HTTP silently negotiates gzip, and its inflater buffers the
|
|
111
|
+
# whole stream, delivering "live" events in one burst at the end.
|
|
112
|
+
request["Accept-Encoding"] = "identity"
|
|
113
|
+
end
|
|
114
|
+
headers.each { |key, value| request[key] = value }
|
|
115
|
+
request.body = JSON.generate(body)
|
|
116
|
+
request
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def with_retry
|
|
120
|
+
attempted = false
|
|
121
|
+
begin
|
|
122
|
+
yield
|
|
123
|
+
rescue Timeout::Error => e
|
|
124
|
+
# The server may already be executing the request; a replay risks
|
|
125
|
+
# running it twice.
|
|
126
|
+
teardown
|
|
127
|
+
raise ProviderError, "request timed out: #{e.message}"
|
|
128
|
+
rescue IOError, SocketError, SystemCallError => e
|
|
129
|
+
teardown
|
|
130
|
+
raise ProviderError, "connection failed: #{e.message}" if attempted
|
|
131
|
+
|
|
132
|
+
attempted = true
|
|
133
|
+
retry
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def connection
|
|
138
|
+
@connection ||= Net::HTTP.new(@uri.host, @uri.port).tap do |http|
|
|
139
|
+
http.use_ssl = @uri.scheme == "https"
|
|
140
|
+
http.open_timeout = @open_timeout
|
|
141
|
+
http.read_timeout = @read_timeout
|
|
142
|
+
http.write_timeout = @write_timeout
|
|
143
|
+
http.keep_alive_timeout = KEEP_ALIVE_SECONDS
|
|
144
|
+
http.start
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def teardown
|
|
149
|
+
@connection&.finish
|
|
150
|
+
rescue IOError
|
|
151
|
+
nil
|
|
152
|
+
ensure
|
|
153
|
+
@connection = nil
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def quietly_finish(conn)
|
|
157
|
+
conn.finish
|
|
158
|
+
rescue IOError
|
|
159
|
+
nil
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def raise_for_status(response)
|
|
163
|
+
status = response.code.to_i
|
|
164
|
+
return if (200..299).cover?(status)
|
|
165
|
+
|
|
166
|
+
klass = error_class(status)
|
|
167
|
+
options = { status: status, body: response.read_body.to_s[0, 500] }
|
|
168
|
+
options[:retry_after] = retry_after(response) if klass == RateLimitError
|
|
169
|
+
raise klass.new(**options)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def error_class(status)
|
|
173
|
+
case status
|
|
174
|
+
when 401, 403 then AuthenticationError
|
|
175
|
+
when 429 then RateLimitError
|
|
176
|
+
when 529 then OverloadedError
|
|
177
|
+
when 500..599 then ServerError
|
|
178
|
+
else ProviderError
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def retry_after(response)
|
|
183
|
+
value = response["retry-after"]
|
|
184
|
+
value&.match?(/\A\d+(\.\d+)?\z/) ? value.to_f : nil
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
data/lib/mistri/usage.rb
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
# Token accounting for an assistant turn, and the dollar cost of those tokens.
|
|
5
|
+
#
|
|
6
|
+
# `input` counts only prompt tokens billed at the full rate: cache reads and
|
|
7
|
+
# writes are separate fields, so a cached token is never double-billed.
|
|
8
|
+
# `reasoning` is the thinking slice of `output`. `cache_write_1h` is the slice
|
|
9
|
+
# of `cache_write` held for an hour, which bills at twice the input rate.
|
|
10
|
+
class Usage < Data.define(:input, :output, :cache_read, :cache_write,
|
|
11
|
+
:cache_write_1h, :reasoning, :cost)
|
|
12
|
+
Cost = Data.define(:input, :output, :cache_read, :cache_write, :total) do
|
|
13
|
+
def self.zero = new(input: 0.0, output: 0.0, cache_read: 0.0, cache_write: 0.0, total: 0.0)
|
|
14
|
+
|
|
15
|
+
def +(other)
|
|
16
|
+
self.class.new(input: input + other.input, output: output + other.output,
|
|
17
|
+
cache_read: cache_read + other.cache_read,
|
|
18
|
+
cache_write: cache_write + other.cache_write,
|
|
19
|
+
total: total + other.total)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(input: 0, output: 0, cache_read: 0, cache_write: 0,
|
|
24
|
+
cache_write_1h: 0, reasoning: 0, cost: Cost.zero)
|
|
25
|
+
super
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.zero = new
|
|
29
|
+
|
|
30
|
+
def self.from_h(hash)
|
|
31
|
+
h = (hash || {}).transform_keys(&:to_s)
|
|
32
|
+
c = (h["cost"] || {}).transform_keys(&:to_s)
|
|
33
|
+
new(input: h.fetch("input", 0).to_i, output: h.fetch("output", 0).to_i,
|
|
34
|
+
cache_read: h.fetch("cache_read", 0).to_i, cache_write: h.fetch("cache_write", 0).to_i,
|
|
35
|
+
cache_write_1h: h.fetch("cache_write_1h", 0).to_i,
|
|
36
|
+
reasoning: h.fetch("reasoning", 0).to_i,
|
|
37
|
+
cost: Cost.new(input: c.fetch("input", 0).to_f, output: c.fetch("output", 0).to_f,
|
|
38
|
+
cache_read: c.fetch("cache_read", 0).to_f,
|
|
39
|
+
cache_write: c.fetch("cache_write", 0).to_f,
|
|
40
|
+
total: c.fetch("total", 0).to_f))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def total_tokens = input + output + cache_read + cache_write
|
|
44
|
+
|
|
45
|
+
# A copy with cost computed from per-million-token rates. The 1h cache-write
|
|
46
|
+
# slice bills at the :cache_write_1h rate when given, else at twice the
|
|
47
|
+
# input rate, matching extended-retention pricing.
|
|
48
|
+
def with_cost(rates)
|
|
49
|
+
long = [cache_write_1h, cache_write].min
|
|
50
|
+
short = cache_write - long
|
|
51
|
+
computed = {
|
|
52
|
+
input: rate(rates, :input) * input,
|
|
53
|
+
output: rate(rates, :output) * output,
|
|
54
|
+
cache_read: rate(rates, :cache_read) * cache_read,
|
|
55
|
+
cache_write: (rate(rates, :cache_write) * short) + (long_write_rate(rates) * long)
|
|
56
|
+
}
|
|
57
|
+
with(cost: Cost.new(**computed, total: computed.values.sum))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def +(other)
|
|
61
|
+
self.class.new(input: input + other.input, output: output + other.output,
|
|
62
|
+
cache_read: cache_read + other.cache_read,
|
|
63
|
+
cache_write: cache_write + other.cache_write,
|
|
64
|
+
cache_write_1h: cache_write_1h + other.cache_write_1h,
|
|
65
|
+
reasoning: reasoning + other.reasoning,
|
|
66
|
+
cost: cost + other.cost)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def to_h = super.merge(cost: cost.to_h)
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def rate(rates, key) = rates.fetch(key, 0).to_f / 1_000_000
|
|
74
|
+
|
|
75
|
+
def long_write_rate(rates)
|
|
76
|
+
rates.key?(:cache_write_1h) ? rate(rates, :cache_write_1h) : rate(rates, :input) * 2
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
data/lib/mistri/version.rb
CHANGED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
module Workspace
|
|
5
|
+
# Documents in the host's own database, through a model class the host
|
|
6
|
+
# supplies, optionally scoped (per session, per tenant). Not auto-required;
|
|
7
|
+
# load it with require "mistri/workspace/active_record".
|
|
8
|
+
#
|
|
9
|
+
# The model needs a path column and a content column, unique on path
|
|
10
|
+
# within the scope. A migration to copy:
|
|
11
|
+
#
|
|
12
|
+
# create_table :mistri_documents do |t|
|
|
13
|
+
# t.string :session_id, null: false
|
|
14
|
+
# t.string :path, null: false, limit: 512
|
|
15
|
+
# t.text :content, size: :medium, null: false
|
|
16
|
+
# t.timestamps
|
|
17
|
+
# end
|
|
18
|
+
# add_index :mistri_documents, [:session_id, :path], unique: true
|
|
19
|
+
class ActiveRecord
|
|
20
|
+
def initialize(model, scope: {})
|
|
21
|
+
@model = model
|
|
22
|
+
@scope = scope
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def read(path)
|
|
26
|
+
@model.where(**@scope, path: path.to_s).pick(:content)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def write(path, content)
|
|
30
|
+
record = @model.find_or_initialize_by(**@scope, path: path.to_s)
|
|
31
|
+
record.content = content.to_s
|
|
32
|
+
record.save!
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def delete(path)
|
|
37
|
+
@model.where(**@scope, path: path.to_s).delete_all
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def list(prefix = nil)
|
|
42
|
+
paths = @model.where(**@scope).pluck(:path).sort
|
|
43
|
+
prefix ? paths.select { |p| p.start_with?(prefix.to_s) } : paths
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Mistri
|
|
6
|
+
module Workspace
|
|
7
|
+
# Documents as files under one root. Every path resolves inside the root
|
|
8
|
+
# or raises, so a model-supplied "../../etc/passwd" cannot escape.
|
|
9
|
+
class Directory
|
|
10
|
+
def initialize(root)
|
|
11
|
+
@root = File.expand_path(root)
|
|
12
|
+
FileUtils.mkdir_p(@root)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def read(path)
|
|
16
|
+
full = resolve(path)
|
|
17
|
+
File.exist?(full) ? File.read(full) : nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def write(path, content)
|
|
21
|
+
full = resolve(path)
|
|
22
|
+
FileUtils.mkdir_p(File.dirname(full))
|
|
23
|
+
File.write(full, content.to_s)
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def delete(path)
|
|
28
|
+
full = resolve(path)
|
|
29
|
+
FileUtils.rm_f(full)
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def list(prefix = nil)
|
|
34
|
+
base = @root.length + 1
|
|
35
|
+
paths = Dir.glob(File.join(@root, "**", "*")).select { |f| File.file?(f) }
|
|
36
|
+
.map { |f| f[base..] }.sort
|
|
37
|
+
prefix ? paths.select { |p| p.start_with?(prefix.to_s) } : paths
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def resolve(path)
|
|
43
|
+
full = File.expand_path(path.to_s, @root)
|
|
44
|
+
unless full == @root || full.start_with?("#{@root}#{File::SEPARATOR}")
|
|
45
|
+
raise SchemaError, "path escapes the workspace: #{path}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
full
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
# The document store agents work in. A workspace maps paths to text and can
|
|
5
|
+
# live anywhere: memory for tests and ephemeral runs, a directory when disk
|
|
6
|
+
# exists, the host's database when it does not. The file tools bind to this
|
|
7
|
+
# port, so fuzzy-editing a MySQL row works exactly like editing a file.
|
|
8
|
+
#
|
|
9
|
+
# A backend implements read(path) -> String or nil, write(path, content),
|
|
10
|
+
# delete(path), and list(prefix = nil) -> [paths].
|
|
11
|
+
module Workspace
|
|
12
|
+
class Memory
|
|
13
|
+
def initialize
|
|
14
|
+
@documents = {}
|
|
15
|
+
@mutex = Mutex.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def read(path)
|
|
19
|
+
@mutex.synchronize { @documents[path.to_s] }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def write(path, content)
|
|
23
|
+
@mutex.synchronize { @documents[path.to_s] = content.to_s.dup.freeze }
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def delete(path)
|
|
28
|
+
@mutex.synchronize { @documents.delete(path.to_s) }
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def list(prefix = nil)
|
|
33
|
+
@mutex.synchronize do
|
|
34
|
+
keys = @documents.keys.sort
|
|
35
|
+
prefix ? keys.select { |key| key.start_with?(prefix.to_s) } : keys
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|