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.
@@ -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
@@ -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 the full history stays in the store for
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
@@ -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 approval gates on third-party write tools, retries, sub-agent
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
- emit_event(emit, :toolcall_delta, blocks, content_index: index,
77
- delta: JSON.generate(call.arguments))
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
- Result = Data.define(:message, :status, :pending, :output, :usage) do
16
- def initialize(message:, status:, pending: [], output: nil, usage: Usage.zero)
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