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
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module Mistri
|
|
8
|
+
# The durable record of a run: an append-only log of typed entries over a
|
|
9
|
+
# pluggable store. Messages replay into provider-ready history; other entry
|
|
10
|
+
# types (reminders, custom host data) ride alongside without the loop
|
|
11
|
+
# knowing their shapes.
|
|
12
|
+
#
|
|
13
|
+
# A store implements two methods: append(id, entry_hash) and load(id) ->
|
|
14
|
+
# array of entry hashes in append order. Everything else lives here.
|
|
15
|
+
class Session
|
|
16
|
+
attr_reader :id, :store
|
|
17
|
+
|
|
18
|
+
def initialize(store:, id: nil)
|
|
19
|
+
@store = store
|
|
20
|
+
@id = id || SecureRandom.uuid
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def append_message(message)
|
|
24
|
+
append("message", "message" => message.to_h)
|
|
25
|
+
message
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Entries are normalized through JSON at write time, so every store holds
|
|
29
|
+
# the same canonical shape (string keys, JSON values) and reads behave
|
|
30
|
+
# identically whether the entry round-tripped a database or stayed in
|
|
31
|
+
# memory.
|
|
32
|
+
def append(type, data = {})
|
|
33
|
+
entry = { "type" => type, "at" => Time.now.utc.iso8601 }.merge(data)
|
|
34
|
+
@store.append(@id, JSON.parse(JSON.generate(entry)))
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def entries = @store.load(@id)
|
|
39
|
+
|
|
40
|
+
# The conversation as the model replays it.
|
|
41
|
+
def messages = replay.map(&:first)
|
|
42
|
+
|
|
43
|
+
# Replay messages paired with the entry index each came from, starting at
|
|
44
|
+
# the latest compaction boundary. The synthetic summary message carries a
|
|
45
|
+
# nil index. Compaction places its cuts by these indexes; the full entry
|
|
46
|
+
# log stays in the store for transcript views.
|
|
47
|
+
def replay
|
|
48
|
+
compaction = last_compaction
|
|
49
|
+
from = compaction ? compaction["kept_from"] : 0
|
|
50
|
+
pairs = entries.each_with_index.filter_map do |entry, index|
|
|
51
|
+
[Message.from_h(entry["message"]), index] if index >= from && entry["type"] == "message"
|
|
52
|
+
end
|
|
53
|
+
compaction ? [[summary_message(compaction["summary"]), nil], *pairs] : pairs
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def last_compaction
|
|
57
|
+
entries.reverse_each.find { |entry| entry["type"] == "compaction" }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Queue a message for a running exchange from any process. The loop folds
|
|
61
|
+
# pending steers into the transcript at the next turn boundary, so the
|
|
62
|
+
# model sees them mid-run; one that arrives as the model finishes cleanly
|
|
63
|
+
# extends the run another turn so it is answered, not left dangling.
|
|
64
|
+
def steer(text)
|
|
65
|
+
append("steer", "id" => SecureRandom.uuid, "message" => Message.user(text).to_h)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Steers not yet folded into the transcript, oldest first. The folding
|
|
69
|
+
# message entry carries the steer id, so consumption is derived from the
|
|
70
|
+
# log alone and reads the same from every process.
|
|
71
|
+
def pending_steers
|
|
72
|
+
folded = entries.filter_map { |entry| entry["steer_id"] }
|
|
73
|
+
entries.select { |entry| entry["type"] == "steer" && !folded.include?(entry["id"]) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Record a human's decision on a parked tool call. Decisions are session
|
|
77
|
+
# entries, so they can be written from any process, days later, with no
|
|
78
|
+
# agent constructed; the next resume settles them.
|
|
79
|
+
def approve(call_id, note: nil) = decide(call_id, approved: true, note: note)
|
|
80
|
+
|
|
81
|
+
def deny(call_id, note: nil) = decide(call_id, approved: false, note: note)
|
|
82
|
+
|
|
83
|
+
# Parked tool calls not yet settled by a tool result, each with its
|
|
84
|
+
# decision when one has been recorded. Derived from the entry log alone,
|
|
85
|
+
# so it survives crashes and reads the same from every process.
|
|
86
|
+
def open_approvals
|
|
87
|
+
answered = []
|
|
88
|
+
decisions = {}
|
|
89
|
+
requests = []
|
|
90
|
+
entries.each do |entry|
|
|
91
|
+
case entry["type"]
|
|
92
|
+
when "message"
|
|
93
|
+
call_id = entry.dig("message", "tool_call_id")
|
|
94
|
+
answered << call_id if call_id
|
|
95
|
+
when "approval_request" then requests << entry["call"]
|
|
96
|
+
when "approval_decision" then decisions[entry["call_id"]] = entry
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
requests.reject { |call| answered.include?(call["id"]) }
|
|
100
|
+
.map { |call| { call: rebuild_call(call), decision: decisions[call["id"]] } }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def summary_message(summary)
|
|
106
|
+
Message.user("#{Compaction::SUMMARY_PREFACE}\n\n#{summary}")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def decide(call_id, approved:, note:)
|
|
110
|
+
unless open_approvals.any? { |open| open[:call].id == call_id }
|
|
111
|
+
raise ConfigurationError, "no open approval for #{call_id.inspect}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
entry = { "call_id" => call_id, "approved" => approved }
|
|
115
|
+
entry["note"] = note if note
|
|
116
|
+
append("approval_decision", entry)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def rebuild_call(hash)
|
|
120
|
+
ToolCall.new(id: hash["id"], name: hash["name"],
|
|
121
|
+
arguments: hash["arguments"] || {}, signature: hash["signature"])
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
module Sinks
|
|
5
|
+
# Broadcasts every event to an Action Cable stream as its to_h shape.
|
|
6
|
+
# The server resolves lazily at first call, so this file loads without
|
|
7
|
+
# Rails; pass server: explicitly to use another broadcaster.
|
|
8
|
+
#
|
|
9
|
+
# sink = Mistri::Sinks::ActionCable.new("agent_#{session.id}")
|
|
10
|
+
# agent.run(input, &sink)
|
|
11
|
+
class ActionCable
|
|
12
|
+
def initialize(stream, server: nil)
|
|
13
|
+
@stream = stream
|
|
14
|
+
@server = server
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(event)
|
|
18
|
+
server.broadcast(@stream, event.to_h)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def to_proc = method(:call).to_proc
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def server
|
|
26
|
+
@server ||= ::ActionCable.server
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
module Sinks
|
|
5
|
+
# Wraps any sink and merges bursts of streaming deltas, so a transport
|
|
6
|
+
# broadcasts at UI speed instead of token speed. Deltas buffer per
|
|
7
|
+
# content block and flush merged when the interval elapses or any other
|
|
8
|
+
# event arrives, so ordering is preserved and a turn always ends flushed.
|
|
9
|
+
#
|
|
10
|
+
# sink = Mistri::Sinks::Coalesced.new(
|
|
11
|
+
# Mistri::Sinks::ActionCable.new("agent_1"), interval: 0.1,
|
|
12
|
+
# )
|
|
13
|
+
class Coalesced
|
|
14
|
+
DELTAS = %i[text_delta thinking_delta toolcall_delta].freeze
|
|
15
|
+
|
|
16
|
+
def initialize(sink, interval: 0.05)
|
|
17
|
+
@sink = sink
|
|
18
|
+
@interval = interval
|
|
19
|
+
@buffer = nil
|
|
20
|
+
@flushed_at = now
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call(event)
|
|
24
|
+
unless DELTAS.include?(event.type)
|
|
25
|
+
flush
|
|
26
|
+
return @sink.call(event)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
merge(event)
|
|
30
|
+
flush if now - @flushed_at >= @interval
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def to_proc = method(:call).to_proc
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# Merged deltas keep the newest partial, so a consumer that renders
|
|
38
|
+
# snapshots always renders the latest one.
|
|
39
|
+
def merge(event)
|
|
40
|
+
same = @buffer && @buffer.type == event.type &&
|
|
41
|
+
@buffer.content_index == event.content_index
|
|
42
|
+
flush if @buffer && !same
|
|
43
|
+
@buffer = if same
|
|
44
|
+
@buffer.with(delta: @buffer.delta.to_s + event.delta.to_s,
|
|
45
|
+
partial: event.partial)
|
|
46
|
+
else
|
|
47
|
+
event
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def flush
|
|
52
|
+
pending = @buffer
|
|
53
|
+
@buffer = nil
|
|
54
|
+
@flushed_at = now
|
|
55
|
+
@sink.call(pending) if pending
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Mistri
|
|
6
|
+
module Sinks
|
|
7
|
+
# Writes events as Server-Sent Events to any IO-like object: a Rack
|
|
8
|
+
# streaming body, an ActionController::Live stream, a socket. Pure
|
|
9
|
+
# formatting, the outbound counterpart of the Mistri::SSE decoder.
|
|
10
|
+
#
|
|
11
|
+
# response.headers["Content-Type"] = "text/event-stream"
|
|
12
|
+
# agent.run(input, &Mistri::Sinks::SSE.new(response.stream))
|
|
13
|
+
class SSE
|
|
14
|
+
def initialize(io)
|
|
15
|
+
@io = io
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(event)
|
|
19
|
+
@io.write("event: #{event.type}\ndata: #{JSON.generate(event.to_h)}\n\n")
|
|
20
|
+
@io.flush if @io.respond_to?(:flush)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_proc = method(:call).to_proc
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
data/lib/mistri/skill.rb
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
# One expert playbook: a name the model selects by, a description that
|
|
5
|
+
# earns the selection, and the full body it reads before acting. Build
|
|
6
|
+
# them from anywhere — Skills.load reads a directory, and a host with
|
|
7
|
+
# skills in a database constructs these directly.
|
|
8
|
+
Skill = Data.define(:name, :description, :body) do
|
|
9
|
+
def initialize(name:, description: "", body: "")
|
|
10
|
+
raise ConfigurationError, "a skill needs a name" if name.to_s.empty?
|
|
11
|
+
|
|
12
|
+
super(name: name.to_s, description: description.to_s, body: body.to_s)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
# Loads skills and wires them into an agent: their one-line descriptions
|
|
5
|
+
# ride the system prompt, and the model pulls a full body on demand with
|
|
6
|
+
# the read_skill tool — so a large library costs almost nothing until a
|
|
7
|
+
# skill is actually used.
|
|
8
|
+
module Skills
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Reads a directory of skills in either layout: <dir>/<name>/SKILL.md
|
|
12
|
+
# or <dir>/<name>.md. Frontmatter (name:, description:) overrides the
|
|
13
|
+
# path-derived name.
|
|
14
|
+
def load(path)
|
|
15
|
+
raise ConfigurationError, "no skills directory at #{path}" unless File.directory?(path)
|
|
16
|
+
|
|
17
|
+
skills = Dir.children(path).sort.filter_map do |child|
|
|
18
|
+
full = File.join(path, child)
|
|
19
|
+
if File.directory?(full) && File.file?(File.join(full, "SKILL.md"))
|
|
20
|
+
read(File.join(full, "SKILL.md"), default_name: child)
|
|
21
|
+
elsif child.end_with?(".md") && File.file?(full)
|
|
22
|
+
read(full, default_name: File.basename(child, ".md"))
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
skills.sort_by(&:name)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def read(file, default_name:)
|
|
29
|
+
meta, body = parse(File.read(file))
|
|
30
|
+
Skill.new(name: meta.fetch("name", default_name),
|
|
31
|
+
description: meta.fetch("description", ""), body: body)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The always-present cost of a skill library: one line per skill.
|
|
35
|
+
def section(skills)
|
|
36
|
+
lines = skills.map { |skill| "- #{skill.name}: #{skill.description}" }
|
|
37
|
+
<<~TEXT.strip
|
|
38
|
+
## Skills
|
|
39
|
+
|
|
40
|
+
Expert playbooks. Before acting, check this list: when a skill
|
|
41
|
+
matches the task, you MUST call read_skill with its name and follow
|
|
42
|
+
the playbook.
|
|
43
|
+
|
|
44
|
+
#{lines.join("\n")}
|
|
45
|
+
TEXT
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def amend(system, skills)
|
|
49
|
+
return system if skills.empty?
|
|
50
|
+
|
|
51
|
+
[system, section(skills)].compact.join("\n\n")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def reader(skills)
|
|
55
|
+
by_name = skills.to_h { |skill| [skill.name, skill] }
|
|
56
|
+
Tool.define("read_skill", "Reads the full playbook for a named skill.",
|
|
57
|
+
schema: -> { string :name, "Skill name", required: true }) do |args|
|
|
58
|
+
skill = by_name[args["name"]]
|
|
59
|
+
next skill.body if skill
|
|
60
|
+
|
|
61
|
+
"Unknown skill #{args["name"].inspect}. Available: #{by_name.keys.join(", ")}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Frontmatter is deliberately a subset: flat string keys between ---
|
|
66
|
+
# markers, quotes optional. name and description are the whole contract,
|
|
67
|
+
# and a YAML dependency is not worth two fields.
|
|
68
|
+
def parse(text)
|
|
69
|
+
return [{}, text] unless text.start_with?("---\n")
|
|
70
|
+
|
|
71
|
+
head, separator, body = text[4..].partition("\n---\n")
|
|
72
|
+
return [{}, text] if separator.empty?
|
|
73
|
+
|
|
74
|
+
meta = {}
|
|
75
|
+
head.scan(/^([a-z_]+):[ \t]*(.+?)[ \t]*$/) do |key, value|
|
|
76
|
+
meta[key] = value.gsub(/\A["']|["']\z/, "")
|
|
77
|
+
end
|
|
78
|
+
[meta, body.sub(/\A\n+/, "")]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
data/lib/mistri/sse.rb
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Mistri
|
|
6
|
+
# An incremental Server-Sent Events decoder. Feed it raw socket fragments in
|
|
7
|
+
# any chunking; it buffers partial lines across fragments and yields each
|
|
8
|
+
# complete "data:" record as a parsed Hash.
|
|
9
|
+
#
|
|
10
|
+
# The decode is deliberately tolerant of what the provider APIs actually
|
|
11
|
+
# send: one single-line JSON object per "data:" record. "event:", "id:",
|
|
12
|
+
# comment, and blank lines are ignored (the event name is duplicated inside
|
|
13
|
+
# the data payload), OpenAI's "[DONE]" sentinel is dropped, and a record that
|
|
14
|
+
# fails to parse is skipped rather than killing a live stream.
|
|
15
|
+
class SSE
|
|
16
|
+
def initialize
|
|
17
|
+
@buffer = +""
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def feed(fragment, &)
|
|
21
|
+
@buffer << fragment
|
|
22
|
+
while (newline = @buffer.index("\n"))
|
|
23
|
+
line = @buffer.slice!(0, newline + 1)
|
|
24
|
+
decode(line.chomp, &)
|
|
25
|
+
end
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Flush a trailing record that arrived without a final newline.
|
|
30
|
+
def finish(&)
|
|
31
|
+
decode(@buffer.chomp, &) unless @buffer.empty?
|
|
32
|
+
@buffer.clear
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def decode(line)
|
|
39
|
+
return unless line.start_with?("data:")
|
|
40
|
+
|
|
41
|
+
data = line.delete_prefix("data:").strip
|
|
42
|
+
return if data.empty? || data == "[DONE]"
|
|
43
|
+
|
|
44
|
+
decoded = JSON.parse(data)
|
|
45
|
+
yield decoded if decoded.is_a?(Hash)
|
|
46
|
+
rescue JSON::ParserError
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
# Why an assistant turn stopped. Providers map their native finish reasons
|
|
5
|
+
# onto this one set, so the loop and the host never read wire spellings.
|
|
6
|
+
#
|
|
7
|
+
# :stop - the model finished its answer
|
|
8
|
+
# :length - it hit the max-tokens ceiling mid-turn
|
|
9
|
+
# :tool_use - it paused to call one or more tools
|
|
10
|
+
# :error - the provider or runtime failed
|
|
11
|
+
# :aborted - the host cancelled the turn
|
|
12
|
+
# :budget - a configured ceiling stopped the run
|
|
13
|
+
module StopReason
|
|
14
|
+
STOP = :stop
|
|
15
|
+
LENGTH = :length
|
|
16
|
+
TOOL_USE = :tool_use
|
|
17
|
+
ERROR = :error
|
|
18
|
+
ABORTED = :aborted
|
|
19
|
+
BUDGET = :budget
|
|
20
|
+
|
|
21
|
+
ALL = [STOP, LENGTH, TOOL_USE, ERROR, ABORTED, BUDGET].freeze
|
|
22
|
+
|
|
23
|
+
def self.valid?(reason) = ALL.include?(reason)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Mistri
|
|
6
|
+
module Stores
|
|
7
|
+
# Entries in the host's own database, through a model class the host
|
|
8
|
+
# supplies, so sessions live where application data lives: MySQL,
|
|
9
|
+
# Postgres, whatever the app runs. Not auto-required; load it with
|
|
10
|
+
# require "mistri/stores/active_record".
|
|
11
|
+
#
|
|
12
|
+
# The model needs three columns: session_id (string, indexed), position
|
|
13
|
+
# (integer), payload (text). A migration to copy:
|
|
14
|
+
#
|
|
15
|
+
# create_table :mistri_entries do |t|
|
|
16
|
+
# t.string :session_id, null: false, index: true
|
|
17
|
+
# t.integer :position, null: false
|
|
18
|
+
# t.text :payload, size: :medium, null: false
|
|
19
|
+
# t.timestamps
|
|
20
|
+
# end
|
|
21
|
+
# add_index :mistri_entries, [:session_id, :position], unique: true
|
|
22
|
+
#
|
|
23
|
+
# The unique index is load-bearing: one session has one writer at a time
|
|
24
|
+
# (the loop is serial), and if a host ever runs two agents on one session,
|
|
25
|
+
# a colliding append raises instead of silently corrupting entry order.
|
|
26
|
+
#
|
|
27
|
+
# Reads select only (position, payload) and sort in Ruby: ORDER BY over
|
|
28
|
+
# rows carrying multi-megabyte payloads exhausts MySQL's sort buffer.
|
|
29
|
+
class ActiveRecord
|
|
30
|
+
def initialize(model)
|
|
31
|
+
@model = model
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def append(id, entry)
|
|
35
|
+
position = @model.where(session_id: id).maximum(:position).to_i + 1
|
|
36
|
+
@model.create!(session_id: id, position: position, payload: JSON.generate(entry))
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def load(id)
|
|
41
|
+
@model.where(session_id: id).pluck(:position, :payload)
|
|
42
|
+
.sort_by(&:first)
|
|
43
|
+
.map { |_position, payload| JSON.parse(payload) }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Mistri
|
|
7
|
+
module Stores
|
|
8
|
+
# One JSONL file per session under a directory: durable sessions without
|
|
9
|
+
# a database, and a transcript any tool can read line by line.
|
|
10
|
+
class JSONL
|
|
11
|
+
def initialize(dir)
|
|
12
|
+
@dir = dir
|
|
13
|
+
FileUtils.mkdir_p(dir)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def append(id, entry)
|
|
17
|
+
File.open(path(id), "a") { |file| file.puts(JSON.generate(entry)) }
|
|
18
|
+
nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def load(id)
|
|
22
|
+
return [] unless File.exist?(path(id))
|
|
23
|
+
|
|
24
|
+
File.readlines(path(id)).filter_map do |line|
|
|
25
|
+
JSON.parse(line) unless line.strip.empty?
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# Session ids become filenames; anything path-hostile is replaced.
|
|
32
|
+
def path(id)
|
|
33
|
+
File.join(@dir, "#{id.to_s.gsub(/[^a-zA-Z0-9_-]/, "-")}.jsonl")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
module Stores
|
|
5
|
+
# Entries in a plain Hash: for tests and runs that need no persistence.
|
|
6
|
+
class Memory
|
|
7
|
+
def initialize
|
|
8
|
+
@entries = Hash.new { |hash, key| hash[key] = [] }
|
|
9
|
+
@mutex = Mutex.new
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def append(id, entry)
|
|
13
|
+
@mutex.synchronize { @entries[id] << entry }
|
|
14
|
+
nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def load(id)
|
|
18
|
+
@mutex.synchronize { @entries[id].dup }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|