mistri 0.4.0 → 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 +152 -4
- data/README.md +111 -7
- data/lib/mistri/abort_signal.rb +10 -0
- data/lib/mistri/agent.rb +85 -90
- 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 +15 -5
- data/lib/mistri/locks/rails_cache.rb +48 -0
- data/lib/mistri/locks.rb +141 -0
- data/lib/mistri/mcp/client.rb +1 -1
- 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 +24 -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
|
@@ -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,11 +15,18 @@ 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
|
|
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
|
-
# call parks for a human,
|
|
22
|
-
# compaction,
|
|
22
|
+
# call parks for a human, :compacting/:compaction around a context
|
|
23
|
+
# compaction, and :retry (with attempt, max_attempts, delay) before it
|
|
24
|
+
# waits out a transient failure, so one subscription sees the whole
|
|
25
|
+
# exchange. :done and :error are loop-owned and terminal: only the
|
|
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.
|
|
23
30
|
TYPES = %i[
|
|
24
31
|
start
|
|
25
32
|
text_start text_delta text_end
|
|
@@ -29,11 +36,13 @@ module Mistri
|
|
|
29
36
|
tool_result approval_needed
|
|
30
37
|
compacting compaction
|
|
31
38
|
retry
|
|
39
|
+
subagent_report
|
|
32
40
|
].freeze
|
|
33
41
|
|
|
34
42
|
def initialize(type:, content_index: nil, delta: nil, content: nil, tool_call: nil,
|
|
35
43
|
reason: nil, message: nil, error_message: nil, partial: nil, origin: nil,
|
|
36
|
-
duration: nil
|
|
44
|
+
duration: nil, attempt: nil, max_attempts: nil, delay: nil,
|
|
45
|
+
agent: nil, session_id: nil, status: nil)
|
|
37
46
|
raise ArgumentError, "unknown event type #{type.inspect}" unless TYPES.include?(type)
|
|
38
47
|
|
|
39
48
|
super
|
|
@@ -48,7 +57,8 @@ module Mistri
|
|
|
48
57
|
# Partials are ephemeral streaming state and stay out of serialization.
|
|
49
58
|
def to_h
|
|
50
59
|
{ type:, content_index:, delta:, content:, tool_call: tool_call&.to_h,
|
|
51
|
-
reason:, message: message&.to_h, error_message:, origin:, duration
|
|
60
|
+
reason:, message: message&.to_h, error_message:, origin:, duration:,
|
|
61
|
+
attempt:, max_attempts:, delay:, agent:, session_id:, status: }.compact
|
|
52
62
|
end
|
|
53
63
|
end
|
|
54
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/client.rb
CHANGED
|
@@ -24,7 +24,7 @@ module Mistri
|
|
|
24
24
|
# One client serializes its calls; parallel tool calls against one
|
|
25
25
|
# server queue rather than interleave.
|
|
26
26
|
class Client
|
|
27
|
-
PROTOCOL_VERSION = "2025-
|
|
27
|
+
PROTOCOL_VERSION = "2025-11-25"
|
|
28
28
|
SUPPORTED_VERSIONS = %w[2025-11-25 2025-06-18 2025-03-26 2024-11-05].freeze
|
|
29
29
|
LOOPBACK = %w[localhost 127.0.0.1 ::1].freeze
|
|
30
30
|
|
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
|
data/lib/mistri/retry_policy.rb
CHANGED
|
@@ -4,8 +4,8 @@ module Mistri
|
|
|
4
4
|
# When a failed turn is worth retrying, and how long to wait. Transient
|
|
5
5
|
# failures (rate limits, overload, server errors, timeouts, dropped or
|
|
6
6
|
# truncated streams) retry with jittered exponential backoff, honoring the
|
|
7
|
-
# provider's retry-after when it sent one. Everything else
|
|
8
|
-
# requests, our own bugs
|
|
7
|
+
# provider's retry-after when it sent one. Everything else (auth, invalid
|
|
8
|
+
# requests, our own bugs) fails fast.
|
|
9
9
|
#
|
|
10
10
|
# attempts counts retries, not calls: attempts 3 means up to four requests.
|
|
11
11
|
class RetryPolicy
|
|
@@ -21,10 +21,25 @@ module Mistri
|
|
|
21
21
|
@max_delay = max_delay
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
+
# What retries see when a completion answers nothing at all.
|
|
25
|
+
EMPTY_COMPLETION = {
|
|
26
|
+
"type" => "EmptyCompletion",
|
|
27
|
+
"message" => "the provider returned an empty completion"
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
24
30
|
def retry?(error, attempt)
|
|
25
31
|
attempt <= attempts && retryable?(error)
|
|
26
32
|
end
|
|
27
33
|
|
|
34
|
+
# The error a finished attempt carries: the provider's own error, or a
|
|
35
|
+
# synthesized one for a completion that answers nothing at all, which a
|
|
36
|
+
# retry usually clears.
|
|
37
|
+
def error_for(message)
|
|
38
|
+
return message.error if message.stop_reason == StopReason::ERROR
|
|
39
|
+
|
|
40
|
+
EMPTY_COMPLETION if empty?(message)
|
|
41
|
+
end
|
|
42
|
+
|
|
28
43
|
# error is the ErrorData hash from an errored message. A status decides
|
|
29
44
|
# when present; otherwise only known-transient types retry, so schema
|
|
30
45
|
# violations and host bugs never loop.
|
|
@@ -43,5 +58,12 @@ module Mistri
|
|
|
43
58
|
exponential = base * (2**(attempt - 1))
|
|
44
59
|
(exponential * rand(0.5..1.0)).clamp(0.0, max_delay)
|
|
45
60
|
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def empty?(message)
|
|
65
|
+
message.stop_reason == StopReason::STOP &&
|
|
66
|
+
message.content.all? { |block| block.is_a?(Content::Text) && block.text.to_s.strip.empty? }
|
|
67
|
+
end
|
|
46
68
|
end
|
|
47
69
|
end
|
data/lib/mistri/session.rb
CHANGED
|
@@ -66,6 +66,11 @@ module Mistri
|
|
|
66
66
|
entries.reverse_each.find { |entry| entry["type"] == "compaction" }
|
|
67
67
|
end
|
|
68
68
|
|
|
69
|
+
# The inbox: entry types queued for the loop's next turn boundary, each
|
|
70
|
+
# mapped to the marker key its fold leaves on the consuming message
|
|
71
|
+
# entry.
|
|
72
|
+
INBOX = { "steer" => "steer_id", "subagent_report" => "report_id" }.freeze
|
|
73
|
+
|
|
69
74
|
# Queue a message for a running exchange from any process. The loop folds
|
|
70
75
|
# pending steers into the transcript at the next turn boundary, so the
|
|
71
76
|
# model sees them mid-run; one that arrives as the model finishes cleanly
|
|
@@ -74,12 +79,64 @@ module Mistri
|
|
|
74
79
|
append("steer", "id" => SecureRandom.uuid, "message" => Message.user(text).to_h)
|
|
75
80
|
end
|
|
76
81
|
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
#
|
|
82
|
+
# A sub-agent's report, queued for this session the way a steer is: it
|
|
83
|
+
# folds into the transcript at the next turn boundary as a labeled block
|
|
84
|
+
# the model can react to ("[Magpie finished] <report>"), while the typed
|
|
85
|
+
# entry keeps name, status, and the raw text for hosts to render as a
|
|
86
|
+
# report card rather than a fake user message. One report per child,
|
|
87
|
+
# ever: a duplicate delivery (a redelivered queue job, a lease race) is
|
|
88
|
+
# dropped, and the return says which happened. Reports normally arrive
|
|
89
|
+
# via SubAgent.run_dispatched; call this directly only from a custom
|
|
90
|
+
# dispatch path.
|
|
91
|
+
def deliver_report(name:, session_id:, status:, text: nil) # rubocop:disable Naming/PredicateMethod
|
|
92
|
+
already = entries.any? do |entry|
|
|
93
|
+
entry["type"] == "subagent_report" && entry["session_id"] == session_id
|
|
94
|
+
end
|
|
95
|
+
return false if already
|
|
96
|
+
|
|
97
|
+
append("subagent_report", "id" => SecureRandom.uuid, "name" => name,
|
|
98
|
+
"session_id" => session_id, "status" => status, "report" => text,
|
|
99
|
+
"message" => Message.user(report_label(name, status, text)).to_h)
|
|
100
|
+
true
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Everything queued for the loop's next turn boundary (steers and
|
|
104
|
+
# sub-agent reports), oldest first, in arrival order. The folding
|
|
105
|
+
# message entry carries the source entry's id under its marker key, so
|
|
106
|
+
# consumption is derived from the log alone and reads the same from
|
|
107
|
+
# every process. A host that wakes an idle session when a steer arrives
|
|
108
|
+
# should watch this instead: a report deserves the same pickup.
|
|
109
|
+
def pending_inbox
|
|
110
|
+
log = entries
|
|
111
|
+
folded = log.flat_map { |entry| entry.values_at(*INBOX.values) }.compact.to_set
|
|
112
|
+
log.select { |entry| INBOX.key?(entry["type"]) && !folded.include?(entry["id"]) }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The steer-only slice of the inbox, oldest first.
|
|
80
116
|
def pending_steers
|
|
81
|
-
|
|
82
|
-
|
|
117
|
+
pending_inbox.select { |entry| entry["type"] == "steer" }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Sub-agents this session has spawned, in spawn order: one Child window
|
|
121
|
+
# per link entry, each reading the child's own session. Derived from the
|
|
122
|
+
# log alone, like everything else here.
|
|
123
|
+
def children
|
|
124
|
+
entries.filter_map do |entry|
|
|
125
|
+
next unless entry["type"] == "subagent"
|
|
126
|
+
|
|
127
|
+
Child.new(name: entry["name"], session_id: entry["session_id"], store: @store)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# The session as a reader renders it: entries in order with inline image
|
|
132
|
+
# bytes stripped, and, with include_children, every sub-agent's own
|
|
133
|
+
# transcript spliced in after its link entry. Spliced entries carry an
|
|
134
|
+
# "origin" key shaped exactly like the live stream's event origins
|
|
135
|
+
# ("Magpie#ab12cd34", nesting joined with ">"), so a UI that rebuilds
|
|
136
|
+
# from this sees what it saw live, lanes included, running children's
|
|
137
|
+
# progress-so-far included. The raw log stays available as #entries.
|
|
138
|
+
def transcript(include_children: false)
|
|
139
|
+
splice(entries, include_children: include_children, prefix: nil, seen: Set[@id])
|
|
83
140
|
end
|
|
84
141
|
|
|
85
142
|
# Record a human's decision on a parked tool call. Decisions are session
|
|
@@ -137,6 +194,39 @@ module Mistri
|
|
|
137
194
|
Message.user("#{Compaction::SUMMARY_PREFACE}\n\n#{summary}")
|
|
138
195
|
end
|
|
139
196
|
|
|
197
|
+
# Each link entry opens its child's log in place, depth first, the way
|
|
198
|
+
# nested lanes opened live. The seen set makes expansion idempotent per
|
|
199
|
+
# child (a repeated or self-referencing link renders but never expands
|
|
200
|
+
# twice), so a hostile log cannot loop this.
|
|
201
|
+
def splice(log, include_children:, prefix:, seen:)
|
|
202
|
+
log.flat_map do |entry|
|
|
203
|
+
rendered = Child.strip_images(entry)
|
|
204
|
+
rendered = rendered.merge("origin" => prefix) if prefix
|
|
205
|
+
next [rendered] unless include_children && expandable?(entry, seen)
|
|
206
|
+
|
|
207
|
+
seen << entry["session_id"]
|
|
208
|
+
origin = "#{entry["name"]}##{entry["session_id"][0, 8]}"
|
|
209
|
+
origin = "#{prefix}>#{origin}" if prefix
|
|
210
|
+
child_log = self.class.new(store: @store, id: entry["session_id"]).entries
|
|
211
|
+
[rendered, *splice(child_log, include_children: true, prefix: origin, seen: seen)]
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def expandable?(entry, seen)
|
|
216
|
+
entry["type"] == "subagent" && !seen.include?(entry["session_id"])
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# How a report reads to the model: labeled with the worker's name and
|
|
220
|
+
# fate, so the parent knows exactly who finished and how.
|
|
221
|
+
def report_label(name, status, text)
|
|
222
|
+
case status
|
|
223
|
+
when "done" then "[#{name} finished] #{text}"
|
|
224
|
+
when "failed" then "[#{name} failed] #{text}"
|
|
225
|
+
when "stopped" then "[#{name} was stopped]"
|
|
226
|
+
else "[#{name} ended: #{status}]"
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
140
230
|
def decide(call_id, approved:, note:)
|
|
141
231
|
unless open_approvals.any? { |open| open[:call].id == call_id }
|
|
142
232
|
raise ConfigurationError, "no open approval for #{call_id.inspect}"
|
data/lib/mistri/skill.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Mistri
|
|
4
4
|
# One expert playbook: a name the model selects by, a description that
|
|
5
5
|
# earns the selection, and the full body it reads before acting. Build
|
|
6
|
-
# them from anywhere
|
|
6
|
+
# them from anywhere: Skills.load reads a directory, and a host with
|
|
7
7
|
# skills in a database constructs these directly.
|
|
8
8
|
Skill = Data.define(:name, :description, :body) do
|
|
9
9
|
def initialize(name:, description: "", body: "")
|
data/lib/mistri/skills.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Mistri
|
|
4
4
|
# Loads skills and wires them into an agent: their one-line descriptions
|
|
5
5
|
# ride the system prompt, and the model pulls a full body on demand with
|
|
6
|
-
# the read_skill tool
|
|
6
|
+
# the read_skill tool, so a large library costs almost nothing until a
|
|
7
7
|
# skill is actually used.
|
|
8
8
|
module Skills
|
|
9
9
|
module_function
|