mistri 0.4.1 → 0.5.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 +132 -4
- data/README.md +88 -7
- data/lib/mistri/abort_signal.rb +10 -0
- data/lib/mistri/agent.rb +70 -63
- data/lib/mistri/child.rb +130 -0
- data/lib/mistri/compactor.rb +1 -1
- data/lib/mistri/console.rb +188 -0
- data/lib/mistri/dispatchers.rb +47 -0
- data/lib/mistri/event.rb +9 -3
- data/lib/mistri/locks/rails_cache.rb +48 -0
- data/lib/mistri/locks.rb +141 -0
- data/lib/mistri/mcp.rb +1 -1
- data/lib/mistri/providers/fake.rb +16 -2
- data/lib/mistri/result.rb +7 -2
- data/lib/mistri/retry_policy.rb +2 -2
- data/lib/mistri/session.rb +95 -5
- data/lib/mistri/skill.rb +1 -1
- data/lib/mistri/skills.rb +1 -1
- data/lib/mistri/spawner.rb +266 -0
- data/lib/mistri/stores/active_record.rb +21 -6
- data/lib/mistri/stores/jsonl.rb +3 -1
- data/lib/mistri/sub_agent.rb +191 -93
- data/lib/mistri/task_output.rb +40 -0
- data/lib/mistri/tool.rb +10 -1
- data/lib/mistri/tool_context.rb +3 -3
- data/lib/mistri/version.rb +1 -1
- data/lib/mistri.rb +11 -0
- metadata +8 -1
data/lib/mistri/child.rb
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
# A sub-agent as its parent (or the host UI) sees it: a window onto the
|
|
5
|
+
# child's own session. Everything here is derived from the store, so it
|
|
6
|
+
# reads the same from any process, while the child runs and forever after.
|
|
7
|
+
#
|
|
8
|
+
# session.children # => [#<Mistri::Child Magpie done>, ...]
|
|
9
|
+
# child.status # => :running, :done, :stopped, :failed
|
|
10
|
+
# child.report # the terminal entry's report, once finished
|
|
11
|
+
# child.transcript(tail: 20) # recent entries, image bytes stripped
|
|
12
|
+
# child.say("Also check their pricing page")
|
|
13
|
+
#
|
|
14
|
+
# Status is a walk over the child's own entries: a terminal entry wins; a
|
|
15
|
+
# started child is :running while its lease holds and :interrupted once
|
|
16
|
+
# it lapses (with a lock adapter; without one there is no liveness signal
|
|
17
|
+
# and no-terminal stays :running); a dispatched-but-never-started child is
|
|
18
|
+
# :queued, honestly, because the host's queue owns that gap.
|
|
19
|
+
class Child
|
|
20
|
+
TERMINAL = "subagent_result"
|
|
21
|
+
DISPATCHED = "subagent_dispatched"
|
|
22
|
+
STARTED = "subagent_started"
|
|
23
|
+
# The states a worker can still be caught in: steerable, stoppable,
|
|
24
|
+
# worth waiting on.
|
|
25
|
+
LIVE = %i[running queued].freeze
|
|
26
|
+
|
|
27
|
+
attr_reader :name, :session_id
|
|
28
|
+
|
|
29
|
+
def self.lease_key(session_id) = "child:#{session_id}"
|
|
30
|
+
|
|
31
|
+
def self.stop_key(session_id) = "child-stop:#{session_id}"
|
|
32
|
+
|
|
33
|
+
def initialize(name:, session_id:, store:)
|
|
34
|
+
@name = name
|
|
35
|
+
@session_id = session_id
|
|
36
|
+
@store = store
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def status
|
|
40
|
+
log = session.entries
|
|
41
|
+
terminal = log.reverse_each.find { |entry| entry["type"] == TERMINAL }
|
|
42
|
+
return terminal["status"].to_sym if terminal
|
|
43
|
+
if log.any? { |entry| entry["type"] == DISPATCHED } &&
|
|
44
|
+
log.none? { |entry| entry["type"] == STARTED }
|
|
45
|
+
return :queued
|
|
46
|
+
end
|
|
47
|
+
return :interrupted if Mistri.locks && !Mistri.locks.held?(self.class.lease_key(@session_id))
|
|
48
|
+
|
|
49
|
+
:running
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# A terminal entry exists: the child ended as done, stopped, or failed
|
|
53
|
+
# and will never run again. The question a queue retry asks; its
|
|
54
|
+
# inverse (started but no terminal) is what makes a crashed child
|
|
55
|
+
# re-runnable.
|
|
56
|
+
def finished?
|
|
57
|
+
!terminal_entry.nil?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def report
|
|
61
|
+
terminal_entry&.fetch("report", nil)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The terminal entry's error string, once failed.
|
|
65
|
+
def error
|
|
66
|
+
terminal_entry&.fetch("error", nil)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Recent entries, oldest first, with inline image bytes replaced by a
|
|
70
|
+
# marker: transcripts are for reading and re-sending, not for hauling
|
|
71
|
+
# screenshots back into a context window.
|
|
72
|
+
def transcript(tail: 20)
|
|
73
|
+
entries = session.entries
|
|
74
|
+
entries = entries.last(tail) if tail
|
|
75
|
+
entries.map { |entry| self.class.strip_images(entry) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Queue a message the child folds at its next turn boundary. Delivery is
|
|
79
|
+
# honest, not instant: a child mid-step sees it after that step, and a
|
|
80
|
+
# child that finishes first never sees it.
|
|
81
|
+
def say(text)
|
|
82
|
+
session.steer(text)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Ask a live child to stop, from any process. A running child's runner
|
|
86
|
+
# sees the flag within a tick and trips its own signal; a queued child
|
|
87
|
+
# is cancelled outright with a stopped terminal, which the runner
|
|
88
|
+
# honors by never starting it. Stop is cross-process by nature, so it
|
|
89
|
+
# needs a lock adapter; without one this returns false. An action that
|
|
90
|
+
# reports acceptance, not a predicate.
|
|
91
|
+
def stop # rubocop:disable Naming/PredicateMethod
|
|
92
|
+
return false unless Mistri.locks
|
|
93
|
+
|
|
94
|
+
session.append(TERMINAL, "status" => "stopped") if status == :queued
|
|
95
|
+
Mistri.locks.set_flag(self.class.stop_key(@session_id))
|
|
96
|
+
true
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def to_h
|
|
100
|
+
{ "name" => name, "session_id" => session_id, "status" => status.to_s }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def inspect
|
|
104
|
+
"#<Mistri::Child #{name} #{status}>"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def self.strip_images(value)
|
|
108
|
+
case value
|
|
109
|
+
when Hash
|
|
110
|
+
if value["data"] && value["mime_type"]
|
|
111
|
+
value.except("data").merge("omitted" => true)
|
|
112
|
+
else
|
|
113
|
+
value.transform_values { |nested| strip_images(nested) }
|
|
114
|
+
end
|
|
115
|
+
when Array then value.map { |nested| strip_images(nested) }
|
|
116
|
+
else value
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def session
|
|
123
|
+
@session ||= Session.new(store: @store, id: @session_id)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def terminal_entry
|
|
127
|
+
session.entries.reverse_each.find { |entry| entry["type"] == TERMINAL }
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
data/lib/mistri/compactor.rb
CHANGED
|
@@ -5,7 +5,7 @@ require "json"
|
|
|
5
5
|
module Mistri
|
|
6
6
|
# Compacts a session in place: everything before a cut point is summarized
|
|
7
7
|
# by the provider, and a compaction entry redirects replay to the summary
|
|
8
|
-
# plus the kept tail. Append-only
|
|
8
|
+
# plus the kept tail. Append-only: the full history stays in the store for
|
|
9
9
|
# transcript UIs; only what the model sees shrinks. Callable from any
|
|
10
10
|
# process (a UI button, a job), with or without a running agent.
|
|
11
11
|
#
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
# The management console: the tools an agent gets for managing the workers
|
|
5
|
+
# it spawns. Every tool is a thin wrapper over Session#children and the
|
|
6
|
+
# Child facade, the same functions a host UI calls, so nothing the agent
|
|
7
|
+
# can do is hidden from the user and nothing the user does confuses the
|
|
8
|
+
# agent. Tools are stateless: each call reads the calling session's
|
|
9
|
+
# children at that moment.
|
|
10
|
+
#
|
|
11
|
+
# agent = Mistri::Agent.new(provider:, tools: [spawn, *Mistri::Console.tools])
|
|
12
|
+
#
|
|
13
|
+
# Workers are addressed by name or session id, uniformly, in every tool.
|
|
14
|
+
# When two workers share a name, the most recently spawned one answers;
|
|
15
|
+
# ids stay unambiguous.
|
|
16
|
+
module Console
|
|
17
|
+
READ_TIMEOUT = 300
|
|
18
|
+
POLL = 0.5
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def tools(read_timeout: READ_TIMEOUT, poll: POLL)
|
|
23
|
+
[list_agents, read_agent(timeout: read_timeout, poll: poll), steer_agent, stop_agent]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def list_agents
|
|
27
|
+
Tool.define(
|
|
28
|
+
"list_agents",
|
|
29
|
+
"See your workers: who is running, who finished, who was stopped. " \
|
|
30
|
+
"Check here before spawning a duplicate."
|
|
31
|
+
) do |_args, context|
|
|
32
|
+
children = context.session.children
|
|
33
|
+
next "You have no workers." if children.empty?
|
|
34
|
+
|
|
35
|
+
children.map do |child|
|
|
36
|
+
"#{child.name} (#{child.session_id[0, 8]}): #{child.status}"
|
|
37
|
+
end.join("\n")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def read_agent(timeout: READ_TIMEOUT, poll: POLL)
|
|
42
|
+
Tool.define(
|
|
43
|
+
"read_agent",
|
|
44
|
+
"Read what a worker has done so far without interrupting it, or pass " \
|
|
45
|
+
"wait to block until it finishes and get its report.",
|
|
46
|
+
schema: lambda {
|
|
47
|
+
string :agent, "The worker's name or session id", required: true
|
|
48
|
+
integer :tail, "How many recent entries to read (default 20)"
|
|
49
|
+
boolean :wait, "Block until the worker finishes, then return its report"
|
|
50
|
+
}
|
|
51
|
+
) do |args, context|
|
|
52
|
+
child = Console.find(context.session, args["agent"])
|
|
53
|
+
next Console.unknown(context.session, args["agent"]) unless child
|
|
54
|
+
|
|
55
|
+
if args["wait"]
|
|
56
|
+
Console.await(child, timeout, poll, context.signal)
|
|
57
|
+
else
|
|
58
|
+
Console.render(child, args.fetch("tail", 20).to_i.clamp(1, 200))
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def steer_agent
|
|
64
|
+
Tool.define(
|
|
65
|
+
"steer_agent",
|
|
66
|
+
"Redirect or correct a running worker. It sees your message at its " \
|
|
67
|
+
"next step; it may finish first. For a full restart, stop it and " \
|
|
68
|
+
"spawn again with better instructions.",
|
|
69
|
+
schema: lambda {
|
|
70
|
+
string :agent, "The worker's name or session id", required: true
|
|
71
|
+
string :message, "What the worker should know or do differently", required: true
|
|
72
|
+
}
|
|
73
|
+
) do |args, context|
|
|
74
|
+
child = Console.find(context.session, args["agent"])
|
|
75
|
+
next Console.unknown(context.session, args["agent"]) unless child
|
|
76
|
+
|
|
77
|
+
status = child.status
|
|
78
|
+
if Child::LIVE.include?(status)
|
|
79
|
+
child.say(args.fetch("message"))
|
|
80
|
+
"Queued. #{child.name} sees it at its next step; it may finish first."
|
|
81
|
+
else
|
|
82
|
+
"#{child.name} is #{status}, so there is nothing to steer. " \
|
|
83
|
+
"Spawn a new worker for follow-up work."
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def stop_agent
|
|
89
|
+
Tool.define(
|
|
90
|
+
"stop_agent",
|
|
91
|
+
"Stop one worker. Its partial work is kept and its transcript stays " \
|
|
92
|
+
"readable. Other workers and your own run continue.",
|
|
93
|
+
schema: lambda {
|
|
94
|
+
string :agent, "The worker's name or session id", required: true
|
|
95
|
+
}
|
|
96
|
+
) do |args, context|
|
|
97
|
+
child = Console.find(context.session, args["agent"])
|
|
98
|
+
next Console.unknown(context.session, args["agent"]) unless child
|
|
99
|
+
|
|
100
|
+
status = child.status
|
|
101
|
+
if !Child::LIVE.include?(status)
|
|
102
|
+
"#{child.name} is already #{status}."
|
|
103
|
+
elsif !child.stop
|
|
104
|
+
"Stopping needs a lock adapter (Mistri.locks) and none is configured."
|
|
105
|
+
elsif status == :queued
|
|
106
|
+
"Cancelled. #{child.name} had not started and never will."
|
|
107
|
+
else
|
|
108
|
+
"Stop requested. #{child.name} halts within a second or two; " \
|
|
109
|
+
"its partial work stays readable through read_agent."
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Ids are advertised as unambiguous, so they resolve first: exact, then
|
|
115
|
+
# an 8+ character prefix (what list_agents displays). Names come last,
|
|
116
|
+
# latest spawn winning on duplicates, so a model-chosen name can never
|
|
117
|
+
# shadow another worker's id.
|
|
118
|
+
def find(session, ref)
|
|
119
|
+
children = session.children
|
|
120
|
+
children.find { |child| child.session_id == ref } ||
|
|
121
|
+
(ref.to_s.length >= 8 &&
|
|
122
|
+
children.find { |child| child.session_id.start_with?(ref) }) ||
|
|
123
|
+
children.reverse_each.find { |child| child.name == ref }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def unknown(session, ref)
|
|
127
|
+
names = session.children.map(&:name).uniq
|
|
128
|
+
known = names.empty? ? "you have no workers" : "your workers: #{names.join(", ")}"
|
|
129
|
+
"No worker matches #{ref.inspect}; #{known}."
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# The wait is cooperative like everything else: the run's abort ends it
|
|
133
|
+
# promptly, never held hostage to the timeout.
|
|
134
|
+
def await(child, timeout, poll, signal = nil)
|
|
135
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
136
|
+
while Child::LIVE.include?(status = child.status)
|
|
137
|
+
return "The wait was stopped; #{child.name} is still #{status}." if signal&.aborted?
|
|
138
|
+
|
|
139
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
140
|
+
return "#{child.name} is still #{status} after #{timeout.round}s. " \
|
|
141
|
+
"Read it without wait to see progress, or stop it."
|
|
142
|
+
end
|
|
143
|
+
sleep poll
|
|
144
|
+
end
|
|
145
|
+
report = child.report
|
|
146
|
+
"#{child.name} #{child.status}." + (report ? "\n#{report}" : "")
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# A compact, readable transcript for a model: one line per entry, text
|
|
150
|
+
# extracted, long values truncated. The gem never summarizes; the
|
|
151
|
+
# caller can.
|
|
152
|
+
def render(child, tail)
|
|
153
|
+
entries = child.transcript(tail: tail)
|
|
154
|
+
return "#{child.name} (#{child.status}): no entries yet." if entries.empty?
|
|
155
|
+
|
|
156
|
+
lines = entries.map { |entry| Console.line(entry) }.compact
|
|
157
|
+
"#{child.name} (#{child.status}), last #{lines.length} entries:\n#{lines.join("\n")}"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def line(entry)
|
|
161
|
+
case entry["type"]
|
|
162
|
+
when "message" then message_line(entry["message"] || {})
|
|
163
|
+
when Child::TERMINAL
|
|
164
|
+
["#{entry["status"]}:", entry["report"] || entry["error"]].compact.join(" ")[0, 300]
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Tool calls ride the message's content as typed blocks, never as a
|
|
169
|
+
# separate key; render both channels from the blocks.
|
|
170
|
+
def message_line(message)
|
|
171
|
+
blocks = message["content"].is_a?(Array) ? message["content"] : []
|
|
172
|
+
calls = blocks.filter_map do |block|
|
|
173
|
+
next unless block.is_a?(Hash) && block["type"] == "tool_call"
|
|
174
|
+
|
|
175
|
+
"#{block["name"]}(#{JSON.generate(block["arguments"] || {})[0, 80]})"
|
|
176
|
+
end
|
|
177
|
+
text = Console.text_of(message["content"])
|
|
178
|
+
parts = [text[0, 240], *calls].reject { |part| part.to_s.empty? }
|
|
179
|
+
"#{message["role"]}: #{parts.join(" | ")}" unless parts.empty?
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def text_of(content)
|
|
183
|
+
return content.to_s unless content.is_a?(Array)
|
|
184
|
+
|
|
185
|
+
content.filter_map { |block| block["text"] if block.is_a?(Hash) }.join(" ")
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
# How a background child actually executes. The spawn tool hands every
|
|
5
|
+
# dispatcher two things: the serializable spec (name, session_id,
|
|
6
|
+
# parent_session_id, type, instructions, tool_names, model, workspace,
|
|
7
|
+
# task) and a runner closure that executes the child in this process with
|
|
8
|
+
# everything already reconstructed.
|
|
9
|
+
#
|
|
10
|
+
# In-process dispatchers just call the runner. A queue dispatcher ignores
|
|
11
|
+
# the runner, enqueues the spec, and its job reconstructs the pieces from
|
|
12
|
+
# host registries and calls SubAgent.run_dispatched:
|
|
13
|
+
#
|
|
14
|
+
# dispatcher: ->(spec, _runner) { ChildRunJob.perform_async(spec) }
|
|
15
|
+
#
|
|
16
|
+
# The runner closes over the spawn-time event sink, so in-process
|
|
17
|
+
# children keep streaming to whoever watched the spawn; that sink must
|
|
18
|
+
# outlive the parent's turn (a broadcast lambda does, a request-scoped
|
|
19
|
+
# object may not).
|
|
20
|
+
module Dispatchers
|
|
21
|
+
# Runs the child synchronously inside the spawn call and still answers
|
|
22
|
+
# in receipt form: background degrades gracefully where no concurrency
|
|
23
|
+
# exists (consoles, tests), and the receipt stays truthful because it
|
|
24
|
+
# reads the child's status after dispatch.
|
|
25
|
+
class Inline
|
|
26
|
+
def call(_spec, runner) = runner.call
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Real in-process background: the child runs on its own thread and the
|
|
30
|
+
# spawn call returns immediately. Enough for development and for hosts
|
|
31
|
+
# whose children are short; queue-backed hosts plug their own lambda.
|
|
32
|
+
class Thread
|
|
33
|
+
def call(spec, runner)
|
|
34
|
+
::Thread.new do
|
|
35
|
+
runner.call
|
|
36
|
+
rescue StandardError => e
|
|
37
|
+
# The runner writes the child's failed terminal before re-raising;
|
|
38
|
+
# a thread must not die loudly into the process, but it must not
|
|
39
|
+
# die silently either, or there is no trace to debug from.
|
|
40
|
+
warn "mistri: background runner for #{spec["name"].inspect} crashed: " \
|
|
41
|
+
"#{e.class}: #{e.message}"
|
|
42
|
+
end
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
data/lib/mistri/event.rb
CHANGED
|
@@ -15,7 +15,8 @@ module Mistri
|
|
|
15
15
|
# events; nil where nothing ran (denials, interruptions).
|
|
16
16
|
class Event < Data.define(:type, :content_index, :delta, :content, :tool_call,
|
|
17
17
|
:reason, :message, :error_message, :partial, :origin,
|
|
18
|
-
:duration, :attempt, :max_attempts, :delay
|
|
18
|
+
:duration, :attempt, :max_attempts, :delay,
|
|
19
|
+
:agent, :session_id, :status)
|
|
19
20
|
# The stream types come from a provider mid-turn; the loop adds
|
|
20
21
|
# :tool_result after it runs each tool, :approval_needed when a gated
|
|
21
22
|
# call parks for a human, :compacting/:compaction around a context
|
|
@@ -23,6 +24,9 @@ module Mistri
|
|
|
23
24
|
# waits out a transient failure, so one subscription sees the whole
|
|
24
25
|
# exchange. :done and :error are loop-owned and terminal: only the
|
|
25
26
|
# accepted attempt's terminal event reaches the subscriber.
|
|
27
|
+
# :subagent_report announces a background child's terminal outcome
|
|
28
|
+
# (agent, session_id, status; content carries the report), so a UI that
|
|
29
|
+
# watched the spawn can settle the child's lane the moment it ends.
|
|
26
30
|
TYPES = %i[
|
|
27
31
|
start
|
|
28
32
|
text_start text_delta text_end
|
|
@@ -32,11 +36,13 @@ module Mistri
|
|
|
32
36
|
tool_result approval_needed
|
|
33
37
|
compacting compaction
|
|
34
38
|
retry
|
|
39
|
+
subagent_report
|
|
35
40
|
].freeze
|
|
36
41
|
|
|
37
42
|
def initialize(type:, content_index: nil, delta: nil, content: nil, tool_call: nil,
|
|
38
43
|
reason: nil, message: nil, error_message: nil, partial: nil, origin: nil,
|
|
39
|
-
duration: nil, attempt: nil, max_attempts: nil, delay: nil
|
|
44
|
+
duration: nil, attempt: nil, max_attempts: nil, delay: nil,
|
|
45
|
+
agent: nil, session_id: nil, status: nil)
|
|
40
46
|
raise ArgumentError, "unknown event type #{type.inspect}" unless TYPES.include?(type)
|
|
41
47
|
|
|
42
48
|
super
|
|
@@ -52,7 +58,7 @@ module Mistri
|
|
|
52
58
|
def to_h
|
|
53
59
|
{ type:, content_index:, delta:, content:, tool_call: tool_call&.to_h,
|
|
54
60
|
reason:, message: message&.to_h, error_message:, origin:, duration:,
|
|
55
|
-
attempt:, max_attempts:, delay: }.compact
|
|
61
|
+
attempt:, max_attempts:, delay:, agent:, session_id:, status: }.compact
|
|
56
62
|
end
|
|
57
63
|
end
|
|
58
64
|
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Opt-in, exactly like Stores::ActiveRecord: the gem never requires Rails.
|
|
4
|
+
# require "mistri/locks/rails_cache".
|
|
5
|
+
module Mistri
|
|
6
|
+
module Locks
|
|
7
|
+
# Leases and flags over an ActiveSupport::Cache::Store (Redis-backed in
|
|
8
|
+
# any real deployment), so every process in the fleet reads the same
|
|
9
|
+
# truth. Pass any cache store duck; defaults to Rails.cache.
|
|
10
|
+
class RailsCache
|
|
11
|
+
def initialize(cache: nil, namespace: "mistri:locks")
|
|
12
|
+
@cache = cache || (defined?(Rails) && Rails.cache) ||
|
|
13
|
+
raise(ConfigurationError, "no cache store; pass cache: or configure Rails.cache")
|
|
14
|
+
@namespace = namespace
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def acquire(key, ttl:)
|
|
18
|
+
@cache.write(scoped(key), true, unless_exist: true, expires_in: ttl)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def renew(key, ttl:)
|
|
22
|
+
@cache.write(scoped(key), true, expires_in: ttl)
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def release(key)
|
|
27
|
+
@cache.delete(scoped(key))
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def held?(key)
|
|
32
|
+
@cache.exist?(scoped(key))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def set_flag(key, ttl: 300)
|
|
36
|
+
renew(key, ttl: ttl)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def flag?(key) = held?(key)
|
|
40
|
+
|
|
41
|
+
def clear_flag(key) = release(key)
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def scoped(key) = "#{@namespace}:#{key}"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
data/lib/mistri/locks.rb
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
module Mistri
|
|
6
|
+
# Cross-process coordination for hosts that run loops in more than one
|
|
7
|
+
# process: leases that say "this is alive right now" and flags that carry
|
|
8
|
+
# one-bit requests (stop) between processes. Everything expires on its
|
|
9
|
+
# own, so a crashed holder never wedges anything; a live holder renews on
|
|
10
|
+
# a heartbeat, so only dead ones expire.
|
|
11
|
+
#
|
|
12
|
+
# Configure once at boot:
|
|
13
|
+
#
|
|
14
|
+
# Mistri.locks = Mistri::Locks::Memory.new # single process
|
|
15
|
+
# Mistri.locks = Mistri::Locks::RailsCache.new # requires opt-in file
|
|
16
|
+
#
|
|
17
|
+
# An adapter implements six methods: acquire(key, ttl:) -> bool,
|
|
18
|
+
# renew(key, ttl:), release(key), held?(key), set_flag(key, ttl:),
|
|
19
|
+
# flag?(key), clear_flag(key). With no adapter configured, everything
|
|
20
|
+
# lease-aware degrades gracefully: children read :running instead of
|
|
21
|
+
# :interrupted, and holds are no-ops.
|
|
22
|
+
module Locks
|
|
23
|
+
LEASE_TTL = 180
|
|
24
|
+
HEARTBEAT = 60
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# Hold a lease for the duration of a block of work: acquire, renew on a
|
|
29
|
+
# heartbeat from a background thread, release on the way out. Returns a
|
|
30
|
+
# Hold to release in an ensure, or nil when no adapter is configured or
|
|
31
|
+
# the lease is already held elsewhere: a caller that was refused must
|
|
32
|
+
# never renew or delete the real holder's key. Callers that need to
|
|
33
|
+
# tell refusal apart from no-adapter use the adapter's acquire directly.
|
|
34
|
+
#
|
|
35
|
+
# With stop_key: and signal:, the same thread watches the flag and trips
|
|
36
|
+
# the signal within a tick, so a stop request written in another process
|
|
37
|
+
# becomes this run's cooperative abort.
|
|
38
|
+
def hold(key, ttl: LEASE_TTL, heartbeat: HEARTBEAT, stop_key: nil, signal: nil)
|
|
39
|
+
adapter = Mistri.locks
|
|
40
|
+
return nil unless adapter
|
|
41
|
+
return nil unless adapter.acquire(key, ttl: ttl)
|
|
42
|
+
|
|
43
|
+
Hold.new(adapter, key, ttl, heartbeat, stop_key: stop_key, signal: signal)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# A held lease: one thread renews it on the heartbeat and watches the
|
|
47
|
+
# stop flag between renewals. Renewal runs on a monotonic deadline (the
|
|
48
|
+
# last sleep before it is the remaining fraction, never a full tick, so
|
|
49
|
+
# the cadence is exact and a lease can never lapse waiting for a tick to
|
|
50
|
+
# round up). release stops the thread (and joins it, so a mid-renewal
|
|
51
|
+
# tick can never re-stamp a lease that was just released) and deletes
|
|
52
|
+
# the key.
|
|
53
|
+
class Hold
|
|
54
|
+
def initialize(adapter, key, ttl, heartbeat, stop_key: nil, signal: nil)
|
|
55
|
+
@adapter = adapter
|
|
56
|
+
@key = key
|
|
57
|
+
@stopping = false
|
|
58
|
+
@thread = Thread.new do
|
|
59
|
+
deadline = now + heartbeat
|
|
60
|
+
until @stopping
|
|
61
|
+
sleep (deadline - now).clamp(0.01, 1.0)
|
|
62
|
+
break if @stopping
|
|
63
|
+
|
|
64
|
+
signal.abort!("stopped by user") if stop_key && signal && adapter.flag?(stop_key)
|
|
65
|
+
if now >= deadline
|
|
66
|
+
adapter.renew(key, ttl: ttl)
|
|
67
|
+
deadline = now + heartbeat
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def release
|
|
74
|
+
@stopping = true
|
|
75
|
+
@thread.kill
|
|
76
|
+
@thread.join(2)
|
|
77
|
+
@adapter.release(@key)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The in-process adapter: real TTL semantics over a hash, for tests,
|
|
86
|
+
# development, and the thread dispatcher. Monitor-synchronized; expiry
|
|
87
|
+
# is judged at read time, so nothing needs a sweeper.
|
|
88
|
+
class Memory
|
|
89
|
+
def initialize
|
|
90
|
+
@entries = {}
|
|
91
|
+
@lock = Monitor.new
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def acquire(key, ttl:)
|
|
95
|
+
@lock.synchronize do
|
|
96
|
+
return false if live?(key)
|
|
97
|
+
|
|
98
|
+
@entries[key] = deadline(ttl)
|
|
99
|
+
true
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def renew(key, ttl:)
|
|
104
|
+
@lock.synchronize { @entries[key] = deadline(ttl) }
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def release(key)
|
|
109
|
+
@lock.synchronize { @entries.delete(key) }
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def held?(key)
|
|
114
|
+
@lock.synchronize { live?(key) }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def set_flag(key, ttl: 300)
|
|
118
|
+
renew(key, ttl: ttl)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def flag?(key) = held?(key)
|
|
122
|
+
|
|
123
|
+
def clear_flag(key) = release(key)
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
def live?(key)
|
|
128
|
+
deadline = @entries[key]
|
|
129
|
+
return false unless deadline
|
|
130
|
+
return true if deadline > now
|
|
131
|
+
|
|
132
|
+
@entries.delete(key)
|
|
133
|
+
false
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def deadline(ttl) = now + ttl
|
|
137
|
+
|
|
138
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
data/lib/mistri/mcp.rb
CHANGED
|
@@ -5,7 +5,7 @@ require "json"
|
|
|
5
5
|
module Mistri
|
|
6
6
|
# Bridge Model Context Protocol servers into Mistri tools: list a server's
|
|
7
7
|
# tools, hand them to an agent, and everything the harness already does
|
|
8
|
-
# composes
|
|
8
|
+
# composes: approval gates on third-party write tools, retries, sub-agent
|
|
9
9
|
# pools, the ui channel.
|
|
10
10
|
#
|
|
11
11
|
# client = Mistri::MCP::Client.new(url: "https://mcp.linear.app/mcp",
|
|
@@ -67,18 +67,32 @@ module Mistri
|
|
|
67
67
|
kind == :text ? Content::Text.new(text:) : Content::Thinking.new(thinking: text)
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
+
# Arguments stream in chunks, and every delta's partial carries the
|
|
71
|
+
# in-progress call with the arguments parsed so far, the same shape a
|
|
72
|
+
# real assembler builds, so a consumer that renders tool input as it
|
|
73
|
+
# arrives (a page preview, a code block) is testable headless.
|
|
70
74
|
def stream_tool_call(spec, position, blocks, emit)
|
|
71
75
|
spec = spec.transform_keys(&:to_sym)
|
|
72
76
|
call = ToolCall.new(id: spec[:id] || "call_#{position + 1}", name: spec[:name],
|
|
73
77
|
arguments: (spec[:arguments] || {}).transform_keys(&:to_s))
|
|
74
78
|
index = blocks.size
|
|
75
79
|
emit_event(emit, :toolcall_start, blocks, content_index: index)
|
|
76
|
-
|
|
77
|
-
|
|
80
|
+
built = +""
|
|
81
|
+
JSON.generate(call.arguments).scan(/.{1,#{@chunk_size}}/m) do |chunk|
|
|
82
|
+
built << chunk
|
|
83
|
+
emit_event(emit, :toolcall_delta, blocks + [in_progress(call, built)],
|
|
84
|
+
content_index: index, delta: chunk)
|
|
85
|
+
end
|
|
78
86
|
blocks << call
|
|
79
87
|
emit_event(emit, :toolcall_end, blocks, content_index: index, tool_call: call)
|
|
80
88
|
end
|
|
81
89
|
|
|
90
|
+
def in_progress(call, json)
|
|
91
|
+
parsed = PartialJson.parse(json)
|
|
92
|
+
ToolCall.new(id: call.id, name: call.name,
|
|
93
|
+
arguments: parsed.is_a?(Hash) ? parsed : {})
|
|
94
|
+
end
|
|
95
|
+
|
|
82
96
|
def finish(turn, blocks, emit)
|
|
83
97
|
reason = turn[:stop_reason] ||
|
|
84
98
|
(blocks.any?(ToolCall) ? StopReason::TOOL_USE : StopReason::STOP)
|
data/lib/mistri/result.rb
CHANGED
|
@@ -12,8 +12,12 @@ module Mistri
|
|
|
12
12
|
# output is a task's validated value, nil on plain runs. usage is the
|
|
13
13
|
# run's own accounting: every persisted turn plus compaction calls, summed
|
|
14
14
|
# (a resumed run counts from the resume; task sums across its fix passes).
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
# handed_off marks a run that ended because an ends_turn tool executed:
|
|
16
|
+
# complete, but the final word was the tool's, and whatever comes next (a
|
|
17
|
+
# human's answer) arrives as the next run's input.
|
|
18
|
+
Result = Data.define(:message, :status, :pending, :output, :usage, :handed_off) do
|
|
19
|
+
def initialize(message:, status:, pending: [], output: nil, usage: Usage.zero,
|
|
20
|
+
handed_off: false)
|
|
17
21
|
super
|
|
18
22
|
end
|
|
19
23
|
|
|
@@ -22,6 +26,7 @@ module Mistri
|
|
|
22
26
|
def aborted? = status == :aborted
|
|
23
27
|
def stopped_by_budget? = status == :budget
|
|
24
28
|
def errored? = status == :error
|
|
29
|
+
def handed_off? = handed_off
|
|
25
30
|
|
|
26
31
|
def text = message&.text
|
|
27
32
|
def stop_reason = message&.stop_reason
|