mistri 0.4.1 → 0.6.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.
Files changed (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +596 -3
  3. data/CONTRIBUTING.md +52 -0
  4. data/README.md +291 -306
  5. data/SECURITY.md +40 -0
  6. data/UPGRADING.md +640 -0
  7. data/assets/logo-animated.svg +30 -0
  8. data/assets/logo-dark.svg +14 -0
  9. data/assets/logo-light.svg +14 -0
  10. data/assets/logo.svg +14 -0
  11. data/assets/social-preview.png +0 -0
  12. data/docs/README.md +87 -0
  13. data/docs/context-and-workspaces.md +378 -0
  14. data/docs/mcp.md +366 -0
  15. data/docs/reliability.md +450 -0
  16. data/docs/sessions.md +295 -0
  17. data/docs/sub-agents.md +401 -0
  18. data/docs/tool-contracts.md +324 -0
  19. data/examples/approval.rb +36 -0
  20. data/examples/browser.rb +27 -0
  21. data/examples/page_editor.rb +31 -0
  22. data/examples/quickstart.rb +21 -0
  23. data/lib/generators/mistri/install/install_generator.rb +7 -3
  24. data/lib/generators/mistri/install/templates/migration.rb.tt +2 -2
  25. data/lib/generators/mistri/mcp/templates/migration.rb.tt +1 -1
  26. data/lib/generators/mistri/mcp/templates/model.rb.tt +15 -8
  27. data/lib/mistri/abort_signal.rb +10 -0
  28. data/lib/mistri/agent.rb +635 -108
  29. data/lib/mistri/budget.rb +26 -1
  30. data/lib/mistri/child.rb +186 -0
  31. data/lib/mistri/compaction.rb +26 -10
  32. data/lib/mistri/compactor.rb +35 -12
  33. data/lib/mistri/console.rb +209 -0
  34. data/lib/mistri/content.rb +9 -3
  35. data/lib/mistri/dispatchers.rb +49 -0
  36. data/lib/mistri/errors.rb +83 -4
  37. data/lib/mistri/event.rb +30 -8
  38. data/lib/mistri/event_delivery.rb +60 -0
  39. data/lib/mistri/locks/rails_cache.rb +48 -0
  40. data/lib/mistri/locks.rb +141 -0
  41. data/lib/mistri/mcp/client.rb +74 -19
  42. data/lib/mistri/mcp/egress.rb +216 -0
  43. data/lib/mistri/mcp/oauth.rb +476 -127
  44. data/lib/mistri/mcp/wires.rb +115 -23
  45. data/lib/mistri/mcp.rb +43 -9
  46. data/lib/mistri/message.rb +21 -11
  47. data/lib/mistri/models.rb +160 -22
  48. data/lib/mistri/providers/anthropic/assembler.rb +282 -44
  49. data/lib/mistri/providers/anthropic/serializer.rb +14 -9
  50. data/lib/mistri/providers/anthropic.rb +29 -6
  51. data/lib/mistri/providers/fake.rb +36 -6
  52. data/lib/mistri/providers/gemini/assembler.rb +148 -21
  53. data/lib/mistri/providers/gemini/serializer.rb +78 -9
  54. data/lib/mistri/providers/gemini.rb +31 -5
  55. data/lib/mistri/providers/openai/assembler.rb +337 -60
  56. data/lib/mistri/providers/openai/serializer.rb +13 -12
  57. data/lib/mistri/providers/openai.rb +29 -5
  58. data/lib/mistri/providers/schema_capabilities.rb +214 -0
  59. data/lib/mistri/result.rb +8 -3
  60. data/lib/mistri/retry_policy.rb +2 -2
  61. data/lib/mistri/schema.rb +893 -75
  62. data/lib/mistri/session.rb +649 -47
  63. data/lib/mistri/sinks/coalesced.rb +17 -10
  64. data/lib/mistri/skill.rb +1 -1
  65. data/lib/mistri/skills.rb +1 -1
  66. data/lib/mistri/spawner.rb +316 -0
  67. data/lib/mistri/sse.rb +57 -14
  68. data/lib/mistri/stores/active_record.rb +22 -7
  69. data/lib/mistri/stores/jsonl.rb +3 -1
  70. data/lib/mistri/stores/memory.rb +21 -2
  71. data/lib/mistri/sub_agent/execution.rb +81 -0
  72. data/lib/mistri/sub_agent/runtime.rb +297 -0
  73. data/lib/mistri/sub_agent.rb +238 -103
  74. data/lib/mistri/task_output.rb +58 -0
  75. data/lib/mistri/tool.rb +102 -13
  76. data/lib/mistri/tool_arguments.rb +377 -0
  77. data/lib/mistri/tool_call.rb +43 -9
  78. data/lib/mistri/tool_context.rb +7 -5
  79. data/lib/mistri/tool_executor.rb +117 -26
  80. data/lib/mistri/tool_result.rb +15 -10
  81. data/lib/mistri/tools/edit_file.rb +62 -8
  82. data/lib/mistri/tools.rb +41 -4
  83. data/lib/mistri/transport.rb +149 -44
  84. data/lib/mistri/usage.rb +65 -13
  85. data/lib/mistri/version.rb +1 -1
  86. data/lib/mistri/workspace/active_record.rb +183 -3
  87. data/lib/mistri/workspace/directory.rb +28 -8
  88. data/lib/mistri/workspace/memory.rb +34 -9
  89. data/lib/mistri/workspace/single.rb +62 -5
  90. data/lib/mistri/workspace.rb +39 -0
  91. data/lib/mistri.rb +17 -1
  92. data/mistri.gemspec +34 -0
  93. metadata +38 -3
data/lib/mistri/budget.rb CHANGED
@@ -16,11 +16,36 @@ module Mistri
16
16
 
17
17
  def none? = [@turns, @tokens, @cost_usd, @wall_clock].all?(&:nil?)
18
18
 
19
+ def cost? = !@cost_usd.nil?
20
+
21
+ def validate_provider!(provider)
22
+ return unless cost?
23
+ return if provider.respond_to?(:prices_usage?) && provider.prices_usage?
24
+
25
+ raise ConfigurationError,
26
+ "cost budget requires a catalogued model, list-priced origin, and deterministic " \
27
+ "standard service tier for #{provider.model.inspect}"
28
+ end
29
+
30
+ def validate_usage!(usage)
31
+ return unless cost?
32
+ return if usage.cost.known?
33
+
34
+ raise BudgetError.new(
35
+ "cost budget cannot continue after a request returned unpriced usage; not retrying",
36
+ usage:
37
+ )
38
+ end
39
+
19
40
  # The reason the run should stop, or nil to continue.
20
41
  def exceeded(turns:, usage:, elapsed: 0)
21
42
  return :turns if @turns && turns >= @turns
22
43
  return :tokens if @tokens && usage.total_tokens >= @tokens
23
- return :cost if @cost_usd && usage.cost.total >= @cost_usd
44
+
45
+ if @cost_usd
46
+ validate_usage!(usage)
47
+ return :cost if usage.cost.total >= @cost_usd
48
+ end
24
49
  return :wall_clock if @wall_clock && elapsed >= @wall_clock
25
50
 
26
51
  nil
@@ -0,0 +1,186 @@
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 # queued, running, interrupted, or terminal
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
+ # Work currently scheduled or queued, used for capacity and steering.
24
+ # Interrupted work is nonterminal but not actively live.
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:, parent_session_id: nil)
34
+ @name = name
35
+ @session_id = session_id
36
+ @store = store
37
+ @parent_session_id = parent_session_id
38
+ end
39
+
40
+ def status
41
+ log = session.entries
42
+ terminal = log.reverse_each.find { |entry| entry["type"] == TERMINAL }
43
+ return terminal["status"].to_sym if terminal
44
+ if log.any? { |entry| entry["type"] == DISPATCHED } &&
45
+ log.none? { |entry| entry["type"] == STARTED }
46
+ return :queued
47
+ end
48
+ return :interrupted if Mistri.locks && !Mistri.locks.held?(self.class.lease_key(@session_id))
49
+
50
+ :running
51
+ end
52
+
53
+ # A terminal entry exists: the child ended as done, stopped, or failed,
54
+ # so a later matching delivery must not run it again. The inverse
55
+ # (started but no terminal) is what makes a crashed child 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 child to stop, from any process. An inactive dispatched child is
86
+ # cancelled durably under its lease; an inline or already-running child
87
+ # receives the stop flag. Repeating stop on a stopped dispatched child
88
+ # reconciles a missing parent report. Stop is
89
+ # cross-process by nature, so it needs a lock adapter; without one this
90
+ # returns false. An action that reports acceptance, not a predicate.
91
+ def stop # rubocop:disable Naming/PredicateMethod
92
+ return false unless Mistri.locks
93
+
94
+ outcome = cancel_inactive
95
+ Mistri.locks.set_flag(self.class.stop_key(@session_id)) if outcome == :contended
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
+ # Cancellation and a runner compete for the same lease. Acquiring it
123
+ # proves no unexpired lease is present, so ordinary inactive dispatched
124
+ # work can end here; a stale holder may still resume under the documented
125
+ # tokenless lease trade-off. Status cannot be rechecked because our own
126
+ # lease would read running.
127
+ def cancel_inactive
128
+ key = self.class.lease_key(@session_id)
129
+ lease = Locks.hold(key)
130
+ return :contended unless lease
131
+
132
+ begin
133
+ log = session.entries
134
+ terminal = log.reverse_each.find { |entry| entry["type"] == TERMINAL }
135
+ dispatched = log.any? { |entry| entry["type"] == DISPATCHED }
136
+ if terminal
137
+ deliver_cancellation if terminal["status"] == "stopped"
138
+ :finished
139
+ elsif dispatched
140
+ session.append(TERMINAL, "status" => "stopped")
141
+ deliver_cancellation
142
+ :cancelled
143
+ else
144
+ Mistri.locks.set_flag(self.class.stop_key(@session_id))
145
+ :signaled
146
+ end
147
+ ensure
148
+ lease.release
149
+ end
150
+ end
151
+
152
+ # A cancelled queue item may never be delivered, so the lease owner
153
+ # must route its durable outcome. Current versions trust only the stored
154
+ # grant; legacy children fall back to the parent link because their
155
+ # dispatched entry did not retain a spec.
156
+ def deliver_cancellation
157
+ dispatched = session.entries.find { |entry| entry["type"] == DISPATCHED }
158
+ return unless dispatched
159
+
160
+ spec = dispatched["spec"]
161
+ parent_id, label = cancellation_route(spec)
162
+ return unless parent_id.is_a?(String) && !parent_id.empty?
163
+ return unless label.is_a?(String) && !label.empty?
164
+
165
+ Session.new(store: @store, id: parent_id)
166
+ .deliver_report(name: label, session_id: @session_id, status: "stopped")
167
+ end
168
+
169
+ def cancellation_route(spec)
170
+ return [@parent_session_id, @name] if spec.nil?
171
+ return [nil, nil] unless spec.is_a?(Hash)
172
+ return [nil, nil] unless spec["spec_version"] == SubAgent::DISPATCH_SPEC_VERSION
173
+ return [nil, nil] unless spec["session_id"] == @session_id
174
+
175
+ [spec["parent_session_id"], spec["name"]]
176
+ end
177
+
178
+ def session
179
+ @session ||= Session.new(store: @store, id: @session_id)
180
+ end
181
+
182
+ def terminal_entry
183
+ session.entries.reverse_each.find { |entry| entry["type"] == TERMINAL }
184
+ end
185
+ end
186
+ end
@@ -14,6 +14,7 @@ module Mistri
14
14
  class Compaction
15
15
  DEFAULT_RESERVE = 16_384
16
16
  DEFAULT_KEEP_RECENT = 20_000
17
+ OUTPUT_SAFETY = 4_096
17
18
  IMAGE_CHARS = 4_800
18
19
 
19
20
  SUMMARY_PREFACE = "The earlier conversation was compacted. This summary replaces it:"
@@ -21,28 +22,43 @@ module Mistri
21
22
  attr_reader :reserve, :keep_recent, :window, :instructions
22
23
 
23
24
  # window overrides the model catalog's context window (required for
24
- # models the catalog does not know). instructions add a host-specific
25
- # focus to the summary prompt.
26
- def initialize(reserve: DEFAULT_RESERVE, keep_recent: DEFAULT_KEEP_RECENT,
25
+ # models the catalog does not know). A nil reserve selects automatic
26
+ # headroom; a number is explicit host policy. instructions add a
27
+ # host-specific focus to the summary prompt.
28
+ def initialize(reserve: nil, keep_recent: DEFAULT_KEEP_RECENT,
27
29
  window: nil, instructions: nil)
28
- @reserve = reserve
30
+ @automatic_reserve = reserve.nil?
31
+ @reserve = reserve || DEFAULT_RESERVE
29
32
  @keep_recent = keep_recent
30
33
  @window = window
31
34
  @instructions = instructions
32
35
  end
33
36
 
34
- # Compact when the context has grown into the reserve headroom. An
35
- # unknown window never triggers.
36
- def needed?(tokens, window)
37
- window ? tokens > window - reserve : false
37
+ def automatic_reserve? = @automatic_reserve
38
+
39
+ # Automatic headroom fits output that shares the context window, plus
40
+ # estimation and framing slack. An explicit reserve remains host policy.
41
+ # An unknown window never triggers.
42
+ def needed?(tokens, window, max_output: nil)
43
+ window ? tokens > window - effective_reserve(max_output) : false
44
+ end
45
+
46
+ private
47
+
48
+ def effective_reserve(max_output)
49
+ return reserve unless automatic_reserve?
50
+
51
+ max_output ? [max_output + OUTPUT_SAFETY, reserve].max : reserve
38
52
  end
39
53
 
40
54
  class << self
41
55
  # Context size for a replay: the last healthy turn's reported tokens
42
56
  # (prompt, cache, and output all sit in context next turn) plus an
43
57
  # estimate of every message after it.
44
- def context_tokens(messages)
45
- index = messages.rindex { |message| reported(message) }
58
+ def context_tokens(messages, usage_from: 0)
59
+ index = (usage_from...messages.length).reverse_each.find do |position|
60
+ reported(messages[position])
61
+ end
46
62
  base = index ? reported(messages[index]) : 0
47
63
  messages.drop(index ? index + 1 : 0).sum(base) { |message| estimate(message) }
48
64
  end
@@ -5,14 +5,16 @@ 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
  #
12
- # Cuts land only on user messages, so a tool call and its result always
13
- # stay on the same side, and a parked approval's turn is never cut away
14
- # from the resume that must answer it.
12
+ # Cuts land on user messages or assistant tool-call turns, never results,
13
+ # so every call/result set stays on one side. A parked approval's turn is
14
+ # never cut away from the resume that must answer it.
15
15
  class Compactor
16
+ TOOL_RESULT_MAX_CHARS = 2_000
17
+
16
18
  SUMMARIZER_SYSTEM = <<~PROMPT
17
19
  You are a context summarization assistant. Read the conversation and
18
20
  produce only the structured summary you are asked for. Do not continue
@@ -43,8 +45,8 @@ module Mistri
43
45
  ## Critical Context
44
46
  - [Data, names, or references needed to continue, or "(none)"]
45
47
 
46
- Keep each section concise. Preserve exact identifiers, names, and error
47
- messages.
48
+ Keep each section concise. Preserve exact identifiers, names, paths,
49
+ URLs, commands, numbers, and error messages.
48
50
  FORMAT
49
51
 
50
52
  CHECKPOINT_PROMPT = <<~PROMPT.freeze
@@ -82,7 +84,7 @@ module Mistri
82
84
  return nil if head.empty?
83
85
 
84
86
  emit&.call(Event.new(type: :compacting))
85
- tokens_before = Compaction.context_tokens(replay.map(&:first))
87
+ tokens_before = session.context_tokens
86
88
  reply = summarize(provider, head, previous, settings.instructions)
87
89
  session.append("compaction", "summary" => reply.text,
88
90
  "kept_from" => cut, "tokens_before" => tokens_before)
@@ -95,7 +97,9 @@ module Mistri
95
97
  boundary = keep_boundary(replay, settings.keep_recent)
96
98
  return nil unless boundary
97
99
 
98
- candidates = replay.filter_map { |(message, index)| index if index && message.user? }
100
+ candidates = replay.filter_map do |message, index|
101
+ index if index && cut_candidate?(message)
102
+ end
99
103
  cut = candidates.find { |index| index >= boundary } || candidates.last
100
104
  cut = clamp_to_open_approvals(cut, session)
101
105
  return nil unless cut
@@ -104,8 +108,12 @@ module Mistri
104
108
  first && cut > first ? cut : nil
105
109
  end
106
110
 
111
+ def cut_candidate?(message)
112
+ message.user? || (message.assistant? && message.tool_calls?)
113
+ end
114
+
107
115
  # Walk back from the tail until the keep budget is spent; the cut then
108
- # snaps forward to a user message, so replay keeps at most about
116
+ # snaps to the next safe turn boundary, so replay keeps at most about
109
117
  # keep_recent tokens of recent turns.
110
118
  def keep_boundary(replay, keep_recent)
111
119
  kept = 0
@@ -145,7 +153,10 @@ module Mistri
145
153
  prompt << (previous ? UPDATE_PROMPT : CHECKPOINT_PROMPT)
146
154
  prompt << "\nAdditional focus: #{instructions}\n" if instructions
147
155
  reply = provider.stream(messages: [Message.user(prompt)], system: SUMMARIZER_SYSTEM)
148
- raise CompactionError, "summarization failed: #{reply.error_message}" unless usable?(reply)
156
+ unless usable?(reply)
157
+ raise CompactionError.new("summarization failed: #{reply.error_message}",
158
+ usage: reply.usage)
159
+ end
149
160
 
150
161
  reply
151
162
  end
@@ -155,7 +166,7 @@ module Mistri
155
166
  end
156
167
 
157
168
  def finish(session, reply, tokens_before, &emit)
158
- tokens_after = Compaction.context_tokens(session.messages)
169
+ tokens_after = session.context_tokens
159
170
  emit&.call(Event.new(type: :compaction, content: reply.text))
160
171
  { summary: reply.text, tokens_before: tokens_before,
161
172
  tokens_after: tokens_after, usage: reply.usage }
@@ -169,13 +180,25 @@ module Mistri
169
180
  end
170
181
 
171
182
  def text_of(message)
172
- message.content.filter_map do |block|
183
+ text = message.content.filter_map do |block|
173
184
  case block
174
185
  when Content::Text then block.text
175
186
  when Content::Image then "[image]"
176
187
  when ToolCall then "[called #{block.name} with #{JSON.generate(block.arguments)}]"
177
188
  end
178
189
  end.join("\n")
190
+ message.tool? ? truncate_tool_result(text) : text
191
+ end
192
+
193
+ # Large tool output remains durable and unchanged on ordinary model
194
+ # requests; only the lossy summary wire is bounded, with both ends kept.
195
+ def truncate_tool_result(text)
196
+ return text if text.length <= TOOL_RESULT_MAX_CHARS
197
+
198
+ marker = "\n[tool result truncated; original length: #{text.length} characters]\n"
199
+ visible = TOOL_RESULT_MAX_CHARS - marker.length
200
+ head = (visible * 0.75).floor
201
+ "#{text[0, head]}#{marker}#{text[-(visible - head), visible - head]}"
179
202
  end
180
203
  end
181
204
  end
@@ -0,0 +1,209 @@
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 status == :stopped
102
+ child.stop
103
+ "#{child.name} is already stopped."
104
+ elsif !Child::LIVE.include?(status) && status != :interrupted
105
+ "#{child.name} is already #{status}."
106
+ elsif !child.stop
107
+ "Stopping needs a lock adapter (Mistri.locks) and none is configured."
108
+ else
109
+ stopped = child.status
110
+ if stopped == :stopped
111
+ "Cancelled. #{child.name} is marked stopped; ordinary queue delivery " \
112
+ "will not run it again."
113
+ elsif %i[done failed].include?(stopped)
114
+ "#{child.name} finished as #{stopped} before the stop took effect."
115
+ else
116
+ "Stop requested. #{child.name}'s runner receives the request promptly " \
117
+ "and stops at a cooperative boundary; partial work stays readable."
118
+ end
119
+ end
120
+ end
121
+ end
122
+
123
+ # Ids are advertised as unambiguous, so they resolve first: exact, then
124
+ # an 8+ character prefix (what list_agents displays). Names come last,
125
+ # latest spawn winning on duplicates, so a model-chosen name can never
126
+ # shadow another worker's id.
127
+ def find(session, ref)
128
+ children = session.children
129
+ children.find { |child| child.session_id == ref } ||
130
+ (ref.to_s.length >= 8 &&
131
+ children.find { |child| child.session_id.start_with?(ref) }) ||
132
+ children.reverse_each.find { |child| child.name == ref }
133
+ end
134
+
135
+ def unknown(session, ref)
136
+ names = session.children.map(&:name).uniq
137
+ known = names.empty? ? "you have no workers" : "your workers: #{names.join(", ")}"
138
+ "No worker matches #{ref.inspect}; #{known}."
139
+ end
140
+
141
+ # The wait is cooperative like everything else: the run's abort ends it
142
+ # promptly, never held hostage to the timeout.
143
+ def await(child, timeout, poll, signal = nil)
144
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
145
+ until child.finished?
146
+ status = child.status
147
+ return "The wait was stopped; #{child.name} is still #{status}." if signal&.aborted?
148
+
149
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
150
+ return "#{child.name} is still #{status} after #{timeout.round}s. " \
151
+ "Read it without wait to see progress, or stop it."
152
+ end
153
+ sleep poll
154
+ end
155
+ report = child.report
156
+ "#{child.name} #{child.status}." + (report ? "\n#{report}" : "")
157
+ end
158
+
159
+ # A compact, readable transcript for a model: one line per entry, text
160
+ # extracted, long values truncated. The gem never summarizes; the
161
+ # caller can.
162
+ def render(child, tail)
163
+ entries = child.transcript(tail: tail)
164
+ return "#{child.name} (#{child.status}): no entries yet." if entries.empty?
165
+
166
+ lines = entries.map { |entry| Console.line(entry) }.compact
167
+ "#{child.name} (#{child.status}), last #{lines.length} entries:\n#{lines.join("\n")}"
168
+ end
169
+
170
+ def line(entry)
171
+ case entry["type"]
172
+ when "message" then message_line(entry["message"] || {})
173
+ when Child::TERMINAL
174
+ ["#{entry["status"]}:", entry["report"] || entry["error"]].compact.join(" ")[0, 300]
175
+ end
176
+ end
177
+
178
+ # Tool calls ride the message's content as typed blocks, never as a
179
+ # separate key; render both channels from the blocks.
180
+ def message_line(message)
181
+ blocks = message["content"].is_a?(Array) ? message["content"] : []
182
+ calls = blocks.filter_map do |block|
183
+ next unless block.is_a?(Hash) && block["type"] == "tool_call"
184
+
185
+ "#{block["name"]}(#{Console.render_arguments(block)})"
186
+ end
187
+ text = Console.text_of(message["content"])
188
+ parts = [text[0, 240], *calls].reject { |part| part.to_s.empty? }
189
+ "#{message["role"]}: #{parts.join(" | ")}" unless parts.empty?
190
+ end
191
+
192
+ def render_arguments(block)
193
+ if block["arguments_error"]
194
+ details = { "arguments_error" => block["arguments_error"] }
195
+ details = { "arguments" => block["arguments"] }.merge(details) if block.key?("arguments")
196
+ return JSON.generate(details)[0, 80]
197
+ end
198
+
199
+ arguments = block.key?("arguments") ? block["arguments"] : {}
200
+ JSON.generate(arguments)[0, 80]
201
+ end
202
+
203
+ def text_of(content)
204
+ return content.to_s unless content.is_a?(Array)
205
+
206
+ content.filter_map { |block| block["text"] if block.is_a?(Hash) }.join(" ")
207
+ end
208
+ end
209
+ end
@@ -17,7 +17,10 @@ module Mistri
17
17
  # `signature` carries opaque provider metadata that must round-trip, such as
18
18
  # the OpenAI Responses message id and output phase.
19
19
  Text = Data.define(:text, :signature) do
20
- def initialize(text:, signature: nil) = super(text: Content.freeze_string(text), signature:)
20
+ def initialize(text:, signature: nil)
21
+ signature = Content.freeze_string(signature) if signature.is_a?(String)
22
+ super(text: Content.freeze_string(text), signature:)
23
+ end
21
24
 
22
25
  def type = :text
23
26
 
@@ -29,6 +32,7 @@ module Mistri
29
32
  # filter hid, leaving only the signature.
30
33
  Thinking = Data.define(:thinking, :signature, :redacted) do
31
34
  def initialize(thinking:, signature: nil, redacted: false)
35
+ signature = Content.freeze_string(signature) if signature.is_a?(String)
32
36
  super(thinking: Content.freeze_string(thinking), signature:, redacted:)
33
37
  end
34
38
 
@@ -89,8 +93,10 @@ module Mistri
89
93
  redacted: h.fetch("redacted", false))
90
94
  when "image" then Image.new(data: h["data"], mime_type: h["mime_type"])
91
95
  when "tool_call"
92
- ToolCall.new(id: h["id"], name: h["name"], arguments: h["arguments"] || {},
93
- signature: h["signature"])
96
+ arguments = h.key?("arguments") ? h["arguments"] : {}
97
+ ToolCall.new(id: h["id"], name: h["name"], arguments:,
98
+ signature: h["signature"], arguments_error: h["arguments_error"],
99
+ provider_call_id: h["provider_call_id"])
94
100
  else raise ArgumentError, "unknown content block type #{h["type"].inspect}"
95
101
  end
96
102
  end