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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +596 -3
- data/CONTRIBUTING.md +52 -0
- data/README.md +291 -306
- data/SECURITY.md +40 -0
- data/UPGRADING.md +640 -0
- data/assets/logo-animated.svg +30 -0
- data/assets/logo-dark.svg +14 -0
- data/assets/logo-light.svg +14 -0
- data/assets/logo.svg +14 -0
- data/assets/social-preview.png +0 -0
- data/docs/README.md +87 -0
- data/docs/context-and-workspaces.md +378 -0
- data/docs/mcp.md +366 -0
- data/docs/reliability.md +450 -0
- data/docs/sessions.md +295 -0
- data/docs/sub-agents.md +401 -0
- data/docs/tool-contracts.md +324 -0
- data/examples/approval.rb +36 -0
- data/examples/browser.rb +27 -0
- data/examples/page_editor.rb +31 -0
- data/examples/quickstart.rb +21 -0
- data/lib/generators/mistri/install/install_generator.rb +7 -3
- data/lib/generators/mistri/install/templates/migration.rb.tt +2 -2
- data/lib/generators/mistri/mcp/templates/migration.rb.tt +1 -1
- data/lib/generators/mistri/mcp/templates/model.rb.tt +15 -8
- data/lib/mistri/abort_signal.rb +10 -0
- data/lib/mistri/agent.rb +635 -108
- data/lib/mistri/budget.rb +26 -1
- data/lib/mistri/child.rb +186 -0
- data/lib/mistri/compaction.rb +26 -10
- data/lib/mistri/compactor.rb +35 -12
- data/lib/mistri/console.rb +209 -0
- data/lib/mistri/content.rb +9 -3
- data/lib/mistri/dispatchers.rb +49 -0
- data/lib/mistri/errors.rb +83 -4
- data/lib/mistri/event.rb +30 -8
- data/lib/mistri/event_delivery.rb +60 -0
- data/lib/mistri/locks/rails_cache.rb +48 -0
- data/lib/mistri/locks.rb +141 -0
- data/lib/mistri/mcp/client.rb +74 -19
- data/lib/mistri/mcp/egress.rb +216 -0
- data/lib/mistri/mcp/oauth.rb +476 -127
- data/lib/mistri/mcp/wires.rb +115 -23
- data/lib/mistri/mcp.rb +43 -9
- data/lib/mistri/message.rb +21 -11
- data/lib/mistri/models.rb +160 -22
- data/lib/mistri/providers/anthropic/assembler.rb +282 -44
- data/lib/mistri/providers/anthropic/serializer.rb +14 -9
- data/lib/mistri/providers/anthropic.rb +29 -6
- data/lib/mistri/providers/fake.rb +36 -6
- data/lib/mistri/providers/gemini/assembler.rb +148 -21
- data/lib/mistri/providers/gemini/serializer.rb +78 -9
- data/lib/mistri/providers/gemini.rb +31 -5
- data/lib/mistri/providers/openai/assembler.rb +337 -60
- data/lib/mistri/providers/openai/serializer.rb +13 -12
- data/lib/mistri/providers/openai.rb +29 -5
- data/lib/mistri/providers/schema_capabilities.rb +214 -0
- data/lib/mistri/result.rb +8 -3
- data/lib/mistri/retry_policy.rb +2 -2
- data/lib/mistri/schema.rb +893 -75
- data/lib/mistri/session.rb +649 -47
- data/lib/mistri/sinks/coalesced.rb +17 -10
- data/lib/mistri/skill.rb +1 -1
- data/lib/mistri/skills.rb +1 -1
- data/lib/mistri/spawner.rb +316 -0
- data/lib/mistri/sse.rb +57 -14
- data/lib/mistri/stores/active_record.rb +22 -7
- data/lib/mistri/stores/jsonl.rb +3 -1
- data/lib/mistri/stores/memory.rb +21 -2
- data/lib/mistri/sub_agent/execution.rb +81 -0
- data/lib/mistri/sub_agent/runtime.rb +297 -0
- data/lib/mistri/sub_agent.rb +238 -103
- data/lib/mistri/task_output.rb +58 -0
- data/lib/mistri/tool.rb +102 -13
- data/lib/mistri/tool_arguments.rb +377 -0
- data/lib/mistri/tool_call.rb +43 -9
- data/lib/mistri/tool_context.rb +7 -5
- data/lib/mistri/tool_executor.rb +117 -26
- data/lib/mistri/tool_result.rb +15 -10
- data/lib/mistri/tools/edit_file.rb +62 -8
- data/lib/mistri/tools.rb +41 -4
- data/lib/mistri/transport.rb +149 -44
- data/lib/mistri/usage.rb +65 -13
- data/lib/mistri/version.rb +1 -1
- data/lib/mistri/workspace/active_record.rb +183 -3
- data/lib/mistri/workspace/directory.rb +28 -8
- data/lib/mistri/workspace/memory.rb +34 -9
- data/lib/mistri/workspace/single.rb +62 -5
- data/lib/mistri/workspace.rb +39 -0
- data/lib/mistri.rb +17 -1
- data/mistri.gemspec +34 -0
- 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
|
-
|
|
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
|
data/lib/mistri/child.rb
ADDED
|
@@ -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
|
data/lib/mistri/compaction.rb
CHANGED
|
@@ -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).
|
|
25
|
-
#
|
|
26
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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.
|
|
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
|
data/lib/mistri/compactor.rb
CHANGED
|
@@ -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
|
|
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
|
|
13
|
-
#
|
|
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,
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
data/lib/mistri/content.rb
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
93
|
-
|
|
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
|