Module: Mistri::Console

Defined in:
lib/mistri/console.rb

Overview

The management console: the tools an agent gets for managing the workers it spawns. Every tool is a thin wrapper over Session#children and the Child facade, the same functions a host UI calls, so nothing the agent can do is hidden from the user and nothing the user does confuses the agent. Tools are stateless: each call reads the calling session's children at that moment.

agent = Mistri::Agent.new(provider:, tools: [spawn, *Mistri::Console.tools])

Workers are addressed by name or session id, uniformly, in every tool. When two workers share a name, the most recently spawned one answers; ids stay unambiguous.

Constant Summary collapse

READ_TIMEOUT =
300
POLL =
0.5

Class Method Summary collapse

Class Method Details

.await(child, timeout, poll, signal = nil) ⇒ Object

The wait is cooperative like everything else: the run's abort ends it promptly, never held hostage to the timeout.



143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
# File 'lib/mistri/console.rb', line 143

def await(child, timeout, poll, signal = nil)
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
  until child.finished?
    status = child.status
    return "The wait was stopped; #{child.name} is still #{status}." if signal&.aborted?

    if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
      return "#{child.name} is still #{status} after #{timeout.round}s. " \
             "Read it without wait to see progress, or stop it."
    end
    sleep poll
  end
  report = child.report
  "#{child.name} #{child.status}." + (report ? "\n#{report}" : "")
end

.find(session, ref) ⇒ Object

Ids are advertised as unambiguous, so they resolve first: exact, then an 8+ character prefix (what list_agents displays). Names come last, latest spawn winning on duplicates, so a model-chosen name can never shadow another worker's id.



127
128
129
130
131
132
133
# File 'lib/mistri/console.rb', line 127

def find(session, ref)
  children = session.children
  children.find { |child| child.session_id == ref } ||
    (ref.to_s.length >= 8 &&
      children.find { |child| child.session_id.start_with?(ref) }) ||
    children.reverse_each.find { |child| child.name == ref }
end

.line(entry) ⇒ Object



170
171
172
173
174
175
176
# File 'lib/mistri/console.rb', line 170

def line(entry)
  case entry["type"]
  when "message" then message_line(entry["message"] || {})
  when Child::TERMINAL
    ["#{entry["status"]}:", entry["report"] || entry["error"]].compact.join(" ")[0, 300]
  end
end

.list_agentsObject



26
27
28
29
30
31
32
33
34
35
36
37
38
39
# File 'lib/mistri/console.rb', line 26

def list_agents
  Tool.define(
    "list_agents",
    "See your workers: who is running, who finished, who was stopped. " \
    "Check here before spawning a duplicate."
  ) do |_args, context|
    children = context.session.children
    next "You have no workers." if children.empty?

    children.map do |child|
      "#{child.name} (#{child.session_id[0, 8]}): #{child.status}"
    end.join("\n")
  end
end

.message_line(message) ⇒ Object

Tool calls ride the message's content as typed blocks, never as a separate key; render both channels from the blocks.



180
181
182
183
184
185
186
187
188
189
190
# File 'lib/mistri/console.rb', line 180

def message_line(message)
  blocks = message["content"].is_a?(Array) ? message["content"] : []
  calls = blocks.filter_map do |block|
    next unless block.is_a?(Hash) && block["type"] == "tool_call"

    "#{block["name"]}(#{Console.render_arguments(block)})"
  end
  text = Console.text_of(message["content"])
  parts = [text[0, 240], *calls].reject { |part| part.to_s.empty? }
  "#{message["role"]}: #{parts.join(" | ")}" unless parts.empty?
end

.read_agent(timeout: READ_TIMEOUT, poll: POLL) ⇒ Object



41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
# File 'lib/mistri/console.rb', line 41

def read_agent(timeout: READ_TIMEOUT, poll: POLL)
  Tool.define(
    "read_agent",
    "Read what a worker has done so far without interrupting it, or pass " \
    "wait to block until it finishes and get its report.",
    schema: lambda {
      string :agent, "The worker's name or session id", required: true
      integer :tail, "How many recent entries to read (default 20)"
      boolean :wait, "Block until the worker finishes, then return its report"
    }
  ) do |args, context|
    child = Console.find(context.session, args["agent"])
    next Console.unknown(context.session, args["agent"]) unless child

    if args["wait"]
      Console.await(child, timeout, poll, context.signal)
    else
      Console.render(child, args.fetch("tail", 20).to_i.clamp(1, 200))
    end
  end
end

.render(child, tail) ⇒ Object

A compact, readable transcript for a model: one line per entry, text extracted, long values truncated. The gem never summarizes; the caller can.



162
163
164
165
166
167
168
# File 'lib/mistri/console.rb', line 162

def render(child, tail)
  entries = child.transcript(tail: tail)
  return "#{child.name} (#{child.status}): no entries yet." if entries.empty?

  lines = entries.map { |entry| Console.line(entry) }.compact
  "#{child.name} (#{child.status}), last #{lines.length} entries:\n#{lines.join("\n")}"
end

.render_arguments(block) ⇒ Object



192
193
194
195
196
197
198
199
200
201
# File 'lib/mistri/console.rb', line 192

def render_arguments(block)
  if block["arguments_error"]
    details = { "arguments_error" => block["arguments_error"] }
    details = { "arguments" => block["arguments"] }.merge(details) if block.key?("arguments")
    return JSON.generate(details)[0, 80]
  end

  arguments = block.key?("arguments") ? block["arguments"] : {}
  JSON.generate(arguments)[0, 80]
end

.steer_agentObject



63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
# File 'lib/mistri/console.rb', line 63

def steer_agent
  Tool.define(
    "steer_agent",
    "Redirect or correct a running worker. It sees your message at its " \
    "next step; it may finish first. For a full restart, stop it and " \
    "spawn again with better instructions.",
    schema: lambda {
      string :agent, "The worker's name or session id", required: true
      string :message, "What the worker should know or do differently", required: true
    }
  ) do |args, context|
    child = Console.find(context.session, args["agent"])
    next Console.unknown(context.session, args["agent"]) unless child

    status = child.status
    if Child::LIVE.include?(status)
      child.say(args.fetch("message"))
      "Queued. #{child.name} sees it at its next step; it may finish first."
    else
      "#{child.name} is #{status}, so there is nothing to steer. " \
        "Spawn a new worker for follow-up work."
    end
  end
end

.stop_agentObject



88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
# File 'lib/mistri/console.rb', line 88

def stop_agent
  Tool.define(
    "stop_agent",
    "Stop one worker. Its partial work is kept and its transcript stays " \
    "readable. Other workers and your own run continue.",
    schema: lambda {
      string :agent, "The worker's name or session id", required: true
    }
  ) do |args, context|
    child = Console.find(context.session, args["agent"])
    next Console.unknown(context.session, args["agent"]) unless child

    status = child.status
    if status == :stopped
      child.stop
      "#{child.name} is already stopped."
    elsif !Child::LIVE.include?(status) && status != :interrupted
      "#{child.name} is already #{status}."
    elsif !child.stop
      "Stopping needs a lock adapter (Mistri.locks) and none is configured."
    else
      stopped = child.status
      if stopped == :stopped
        "Cancelled. #{child.name} is marked stopped; ordinary queue delivery " \
          "will not run it again."
      elsif %i[done failed].include?(stopped)
        "#{child.name} finished as #{stopped} before the stop took effect."
      else
        "Stop requested. #{child.name}'s runner receives the request promptly " \
          "and stops at a cooperative boundary; partial work stays readable."
      end
    end
  end
end

.text_of(content) ⇒ Object



203
204
205
206
207
# File 'lib/mistri/console.rb', line 203

def text_of(content)
  return content.to_s unless content.is_a?(Array)

  content.filter_map { |block| block["text"] if block.is_a?(Hash) }.join(" ")
end

.tools(read_timeout: READ_TIMEOUT, poll: POLL) ⇒ Object



22
23
24
# File 'lib/mistri/console.rb', line 22

def tools(read_timeout: READ_TIMEOUT, poll: POLL)
  [list_agents, read_agent(timeout: read_timeout, poll: poll), steer_agent, stop_agent]
end

.unknown(session, ref) ⇒ Object



135
136
137
138
139
# File 'lib/mistri/console.rb', line 135

def unknown(session, ref)
  names = session.children.map(&:name).uniq
  known = names.empty? ? "you have no workers" : "your workers: #{names.join(", ")}"
  "No worker matches #{ref.inspect}; #{known}."
end