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.



134
135
136
137
138
139
140
141
142
143
144
145
146
147
# File 'lib/mistri/console.rb', line 134

def await(child, timeout, poll, signal = nil)
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
  while Child::LIVE.include?(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.



118
119
120
121
122
123
124
# File 'lib/mistri/console.rb', line 118

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



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

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.



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

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"]}(#{JSON.generate(block["arguments"] || {})[0, 80]})"
  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.



152
153
154
155
156
157
158
# File 'lib/mistri/console.rb', line 152

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

.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
# 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 !Child::LIVE.include?(status)
      "#{child.name} is already #{status}."
    elsif !child.stop
      "Stopping needs a lock adapter (Mistri.locks) and none is configured."
    elsif status == :queued
      "Cancelled. #{child.name} had not started and never will."
    else
      "Stop requested. #{child.name} halts within a second or two; " \
        "its partial work stays readable through read_agent."
    end
  end
end

.text_of(content) ⇒ Object



182
183
184
185
186
# File 'lib/mistri/console.rb', line 182

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



126
127
128
129
130
# File 'lib/mistri/console.rb', line 126

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