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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +132 -4
- data/README.md +88 -7
- data/lib/mistri/abort_signal.rb +10 -0
- data/lib/mistri/agent.rb +70 -63
- data/lib/mistri/child.rb +130 -0
- data/lib/mistri/compactor.rb +1 -1
- data/lib/mistri/console.rb +188 -0
- data/lib/mistri/dispatchers.rb +47 -0
- data/lib/mistri/event.rb +9 -3
- data/lib/mistri/locks/rails_cache.rb +48 -0
- data/lib/mistri/locks.rb +141 -0
- data/lib/mistri/mcp.rb +1 -1
- data/lib/mistri/providers/fake.rb +16 -2
- data/lib/mistri/result.rb +7 -2
- data/lib/mistri/retry_policy.rb +2 -2
- data/lib/mistri/session.rb +95 -5
- data/lib/mistri/skill.rb +1 -1
- data/lib/mistri/skills.rb +1 -1
- data/lib/mistri/spawner.rb +266 -0
- data/lib/mistri/stores/active_record.rb +21 -6
- data/lib/mistri/stores/jsonl.rb +3 -1
- data/lib/mistri/sub_agent.rb +191 -93
- data/lib/mistri/task_output.rb +40 -0
- data/lib/mistri/tool.rb +10 -1
- data/lib/mistri/tool_context.rb +3 -3
- data/lib/mistri/version.rb +1 -1
- data/lib/mistri.rb +11 -0
- metadata +8 -1
data/lib/mistri/sub_agent.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Mistri
|
|
4
4
|
# Delegation with a clean context: a child agent runs on its own session
|
|
5
5
|
# (the caller's store, linked in the caller's transcript), and only its
|
|
6
|
-
# final answer returns to the parent
|
|
6
|
+
# final answer returns to the parent; exploration never fills the
|
|
7
7
|
# parent's window. Compaction rescues a full context after the fact;
|
|
8
8
|
# spawning avoids filling it in the first place. A child session is its
|
|
9
9
|
# own single-provider session, so delegating to a cheaper model is the
|
|
@@ -24,7 +24,7 @@ module Mistri
|
|
|
24
24
|
# spawn = Mistri::SubAgent.spawner(provider:, tools: [fetch_page, search])
|
|
25
25
|
#
|
|
26
26
|
# Children never receive a spawn tool: delegation is one level deep by
|
|
27
|
-
# construction. Parallel fan-out costs nothing
|
|
27
|
+
# construction. Parallel fan-out costs nothing: several spawn calls in
|
|
28
28
|
# one turn run concurrently on the executor pool.
|
|
29
29
|
#
|
|
30
30
|
# Child events forward into the parent's stream tagged with origin
|
|
@@ -45,7 +45,7 @@ module Mistri
|
|
|
45
45
|
attr_reader :name, :description
|
|
46
46
|
|
|
47
47
|
# schema: makes the specialist answer in validated JSON (task mode
|
|
48
|
-
# underneath)
|
|
48
|
+
# underneath), so fan-out children return a uniform shape the parent
|
|
49
49
|
# synthesizes instead of five styles of prose.
|
|
50
50
|
def initialize(name:, description:, provider:, system: nil, tools: [], schema: nil,
|
|
51
51
|
**agent_options)
|
|
@@ -62,7 +62,9 @@ module Mistri
|
|
|
62
62
|
|
|
63
63
|
# The delegate tool: each call runs a fresh child and answers with its
|
|
64
64
|
# final text, plus {agent, session_id} on the ui channel so a host can
|
|
65
|
-
# link the child's transcript.
|
|
65
|
+
# link the child's transcript. The model may name each run, so two
|
|
66
|
+
# parallel researchers read as "Corgi" and "Beagle" in lanes and lists
|
|
67
|
+
# instead of "researcher" twice.
|
|
66
68
|
def tool
|
|
67
69
|
sub = self
|
|
68
70
|
blurb = "#{@description} Runs as a focused sub-agent with a clean " \
|
|
@@ -71,39 +73,41 @@ module Mistri
|
|
|
71
73
|
schema: lambda {
|
|
72
74
|
string :task, "Complete instructions for the sub-agent",
|
|
73
75
|
required: true
|
|
76
|
+
string :name, "A short name for this run, shown wherever " \
|
|
77
|
+
"its events appear (default: the tool's name)"
|
|
74
78
|
}) do |args, context|
|
|
75
|
-
sub.run_child(args.fetch("task"), context)
|
|
79
|
+
sub.run_child(args.fetch("task"), context, name: args["name"])
|
|
76
80
|
end
|
|
77
81
|
end
|
|
78
82
|
|
|
79
|
-
def run_child(task, context)
|
|
80
|
-
SubAgent.run_child(label:
|
|
83
|
+
def run_child(task, context, name: nil)
|
|
84
|
+
SubAgent.run_child(label: SubAgent.sanitize_label(name, fallback: @name),
|
|
85
|
+
provider: @provider, system: @system,
|
|
81
86
|
tools: @tools, task: task, context: context, schema: @schema,
|
|
82
87
|
**@agent_options)
|
|
83
88
|
end
|
|
84
89
|
|
|
85
90
|
class << self
|
|
86
|
-
# The open spawn tool
|
|
87
|
-
#
|
|
88
|
-
#
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
def spawner(provider:, tools: [], models: [], needs_approval: false, **agent_options)
|
|
93
|
-
forbid_gated!(tools)
|
|
94
|
-
if tools.any? { |tool| tool.name == "spawn_agent" }
|
|
95
|
-
raise ConfigurationError, "the spawn tool never goes in its own pool"
|
|
96
|
-
end
|
|
91
|
+
# The open spawn tool: the model names each worker, grants it a tool
|
|
92
|
+
# subset from the host's pool, and may pick a type, a model, and a
|
|
93
|
+
# mode. All policy lives on Spawner; this is the front door.
|
|
94
|
+
def spawner(provider:, **)
|
|
95
|
+
Spawner.new(provider: provider, **).tool
|
|
96
|
+
end
|
|
97
97
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
98
|
+
# The whole kit in one call: the spawn tool plus the management
|
|
99
|
+
# console, so a host hands its agent everything workers need.
|
|
100
|
+
def pack(provider:, console: {}, **spawner_options)
|
|
101
|
+
[spawner(provider: provider, **spawner_options), *Console.tools(**console)]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# A worker's display name, made safe for origins: the label rides
|
|
105
|
+
# them as "label#id" and nesting joins with ">", so those separators
|
|
106
|
+
# squeeze to hyphens along with whitespace. Blank falls back.
|
|
107
|
+
def sanitize_label(text, fallback:)
|
|
108
|
+
label = text.to_s.gsub(/[#>\s]+/, "-").squeeze("-")[0, 32]
|
|
109
|
+
label = label.delete_prefix("-").delete_suffix("-")
|
|
110
|
+
label.empty? ? fallback : label
|
|
107
111
|
end
|
|
108
112
|
|
|
109
113
|
def run_child(label:, provider:, system:, tools:, task:, context:, schema: nil,
|
|
@@ -111,16 +115,103 @@ module Mistri
|
|
|
111
115
|
store = context.session ? context.session.store : Stores::Memory.new
|
|
112
116
|
child = Session.new(store: store)
|
|
113
117
|
context.session&.append("subagent", "name" => label, "session_id" => child.id)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
result =
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
118
|
+
# An inline child runs on a signal derived from the parent's: the
|
|
119
|
+
# parent's abort cascades down through the handle, while stopping
|
|
120
|
+
# the child alone leaves the parent running.
|
|
121
|
+
signal, cascade = context.signal ? context.signal.derive : [AbortSignal.new, nil]
|
|
122
|
+
result = begin
|
|
123
|
+
execute_child(child: child, label: label, provider: provider, system: system,
|
|
124
|
+
tools: tools, task: task, schema: schema, signal: signal,
|
|
125
|
+
emit: context.emit, **agent_options)
|
|
126
|
+
ensure
|
|
127
|
+
context.signal&.remove_callback(cascade) if cascade
|
|
128
|
+
end
|
|
129
|
+
outcome = answer(result, label, child)
|
|
130
|
+
child.append(Child::TERMINAL, terminal(result))
|
|
131
|
+
outcome
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# The host job's way back in: reconstruct provider, system, and tools
|
|
135
|
+
# from the spec through host registries, then hand them here. Reopens
|
|
136
|
+
# the child session the spawn created, runs it exactly like an inline
|
|
137
|
+
# child (started entry, lease, stop watching, terminals), streams
|
|
138
|
+
# origin-tagged events to the emit the job supplies, and reports the
|
|
139
|
+
# outcome back to the parent (see report_back). A background child
|
|
140
|
+
# runs on its own signal: the parent's turn is long over, so only
|
|
141
|
+
# stop_agent and the stop flag end it early.
|
|
142
|
+
#
|
|
143
|
+
# The child's lease is the exactly-once fence, so it is taken before
|
|
144
|
+
# anything else: refused means another process is running this child
|
|
145
|
+
# right now (a queue redelivered a live job), so leave its owner alone.
|
|
146
|
+
# Holding it, a terminal decides: present means the child was
|
|
147
|
+
# cancelled while queued or the queue retried a finished job, so
|
|
148
|
+
# there is nothing to run; absent means run, and that includes the
|
|
149
|
+
# child a crashed process left mid-run, which is exactly what queue
|
|
150
|
+
# retries are for. Either kind of no-op returns nil.
|
|
151
|
+
def run_dispatched(spec, provider:, system:, tools:, store:, emit: nil, schema: nil,
|
|
152
|
+
**agent_options)
|
|
153
|
+
child = Session.new(store: store, id: spec.fetch("session_id"))
|
|
154
|
+
signal = AbortSignal.new
|
|
155
|
+
lease = Locks.hold(Child.lease_key(child.id),
|
|
156
|
+
stop_key: Child.stop_key(child.id), signal: signal)
|
|
157
|
+
return nil if Mistri.locks && lease.nil?
|
|
158
|
+
|
|
159
|
+
if Child.new(name: spec.fetch("name"), session_id: child.id, store: store).finished?
|
|
160
|
+
lease&.release
|
|
161
|
+
return nil
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
result = execute_child(child: child, label: spec.fetch("name"), provider: provider,
|
|
165
|
+
system: system, tools: tools, task: spec.fetch("task"),
|
|
166
|
+
schema: schema, signal: signal, emit: emit, lease: lease,
|
|
167
|
+
**agent_options)
|
|
168
|
+
deny_pending(result, child)
|
|
169
|
+
child.append(Child::TERMINAL, terminal(result))
|
|
170
|
+
result
|
|
171
|
+
rescue StandardError => e
|
|
172
|
+
# Completion is a contract even when the runner dies in the
|
|
173
|
+
# preamble: without this, a raise before the started entry (a lock
|
|
174
|
+
# backend down, say) would leave the child reading :queued forever,
|
|
175
|
+
# with nothing to report and nothing for a retry to heal.
|
|
176
|
+
if child && !Child.new(name: spec.fetch("name"), session_id: child.id,
|
|
177
|
+
store: store).finished?
|
|
178
|
+
child.append(Child::TERMINAL,
|
|
179
|
+
"status" => "failed", "error" => "#{e.class}: #{e.message}")
|
|
180
|
+
end
|
|
181
|
+
lease&.release
|
|
182
|
+
raise
|
|
183
|
+
ensure
|
|
184
|
+
report_back(spec, store, emit) if child
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# A child cannot wait for a human, whichever door it entered by: any
|
|
188
|
+
# calls parked for approval are denied AND settled with the denial as
|
|
189
|
+
# their tool result, so no approval request stays open on a finished
|
|
190
|
+
# child and its transcript replays without repair.
|
|
191
|
+
def deny_pending(result, child)
|
|
192
|
+
return unless result.awaiting_approval?
|
|
193
|
+
|
|
194
|
+
result.pending.each do |call|
|
|
195
|
+
child.deny(call.id, note: "sub-agents cannot pause for human approval")
|
|
196
|
+
child.append_message(Message.tool(
|
|
197
|
+
content: "Denied: sub-agents cannot pause for human approval.",
|
|
198
|
+
tool_call_id: call.id, tool_name: call.name
|
|
199
|
+
))
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Every child ends by writing its own terminal entry: completion is a
|
|
204
|
+
# contract, and status stays readable from the store forever.
|
|
205
|
+
def terminal(result)
|
|
206
|
+
case result.status
|
|
207
|
+
when :completed then { "status" => "done", "report" => result.text.to_s }
|
|
208
|
+
when :aborted then { "status" => "stopped" }
|
|
209
|
+
when :awaiting_approval
|
|
210
|
+
{ "status" => "failed",
|
|
211
|
+
"error" => "needed human approval, which sub-agents cannot wait for" }
|
|
212
|
+
else
|
|
213
|
+
{ "status" => "failed", "error" => (result.error_message || result.status).to_s }
|
|
214
|
+
end
|
|
124
215
|
end
|
|
125
216
|
|
|
126
217
|
def forbid_gated!(tools)
|
|
@@ -134,11 +225,69 @@ module Mistri
|
|
|
134
225
|
|
|
135
226
|
private
|
|
136
227
|
|
|
137
|
-
|
|
138
|
-
|
|
228
|
+
# The one hardened execution path every child goes through, whichever
|
|
229
|
+
# door it entered by. From the started entry on, every exit writes a
|
|
230
|
+
# terminal, setup failures included, or the child would read as
|
|
231
|
+
# running forever. The lease says "alive right now" to other
|
|
232
|
+
# processes and its thread watches the child's stop flag; a
|
|
233
|
+
# dispatched run hands in the lease it already holds (its run-or-not
|
|
234
|
+
# decision happened under the fence), an inline child acquires its
|
|
235
|
+
# own here. Either way, this path releases it.
|
|
236
|
+
def execute_child(child:, label:, provider:, system:, tools:, task:, schema:,
|
|
237
|
+
signal:, emit:, lease: nil, **agent_options)
|
|
238
|
+
child.append(Child::STARTED, {})
|
|
239
|
+
lease ||= Locks.hold(Child.lease_key(child.id),
|
|
240
|
+
stop_key: Child.stop_key(child.id), signal: signal)
|
|
241
|
+
begin
|
|
242
|
+
agent = Agent.new(provider: provider, session: child, system: system,
|
|
243
|
+
tools: tools, **agent_options)
|
|
244
|
+
origin = "#{label}##{child.id[0, 8]}"
|
|
245
|
+
tagged = ->(event) { forward(event, origin, emit) }
|
|
246
|
+
if schema
|
|
247
|
+
agent.task(task, schema: schema, signal: signal, &tagged)
|
|
248
|
+
else
|
|
249
|
+
agent.run(task, signal: signal, &tagged)
|
|
250
|
+
end
|
|
251
|
+
rescue StandardError => e
|
|
252
|
+
child.append(Child::TERMINAL, "status" => "failed", "error" => "#{e.class}: #{e.message}")
|
|
253
|
+
raise
|
|
254
|
+
ensure
|
|
255
|
+
lease&.release
|
|
256
|
+
Mistri.locks&.clear_flag(Child.stop_key(child.id))
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def forward(event, origin, emit)
|
|
261
|
+
return unless emit
|
|
139
262
|
|
|
140
263
|
tagged = event.origin ? "#{origin}>#{event.origin}" : origin
|
|
141
|
-
|
|
264
|
+
emit.call(event.with(origin: tagged))
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Every terminal outcome reports back, exactly once. The report joins
|
|
268
|
+
# the parent's inbox (a typed entry that folds at its next turn
|
|
269
|
+
# boundary, exactly like a steer) and a :subagent_report event
|
|
270
|
+
# closes the child's lane in whatever UI watched the spawn. A child
|
|
271
|
+
# that never ran has nothing to say, and the parent session drops a
|
|
272
|
+
# duplicate delivery, so a redelivered job cannot repeat one.
|
|
273
|
+
def report_back(spec, store, emit)
|
|
274
|
+
facade = Child.new(name: spec.fetch("name"), session_id: spec.fetch("session_id"),
|
|
275
|
+
store: store)
|
|
276
|
+
return unless facade.finished?
|
|
277
|
+
|
|
278
|
+
status = facade.status
|
|
279
|
+
text = status == :failed ? facade.error : facade.report
|
|
280
|
+
delivered = if (parent_id = spec["parent_session_id"])
|
|
281
|
+
Session.new(store: store, id: parent_id)
|
|
282
|
+
.deliver_report(name: facade.name, session_id: facade.session_id,
|
|
283
|
+
status: status.to_s, text: text)
|
|
284
|
+
else
|
|
285
|
+
true
|
|
286
|
+
end
|
|
287
|
+
return unless delivered
|
|
288
|
+
|
|
289
|
+
emit&.call(Event.new(type: :subagent_report, agent: facade.name,
|
|
290
|
+
session_id: facade.session_id, status: status, content: text))
|
|
142
291
|
end
|
|
143
292
|
|
|
144
293
|
# The parent always gets an in-band answer it can react to. A child
|
|
@@ -150,68 +299,17 @@ module Mistri
|
|
|
150
299
|
when :completed
|
|
151
300
|
ToolResult.new(content: result.text.to_s, ui: link)
|
|
152
301
|
when :awaiting_approval
|
|
153
|
-
result
|
|
154
|
-
child.deny(call.id, note: "sub-agents cannot pause for human approval")
|
|
155
|
-
end
|
|
302
|
+
deny_pending(result, child)
|
|
156
303
|
ToolResult.new(content: "The #{label} sub-agent stopped: it needed human " \
|
|
157
304
|
"approval, which sub-agents cannot wait for.", ui: link)
|
|
158
305
|
when :aborted
|
|
159
|
-
ToolResult.new(content: "[the #{label} sub-agent was
|
|
306
|
+
ToolResult.new(content: "[the #{label} sub-agent was stopped]", ui: link)
|
|
160
307
|
else
|
|
161
308
|
reason = result.error_message || result.status
|
|
162
309
|
ToolResult.new(content: "The #{label} sub-agent failed: #{reason}", ui: link)
|
|
163
310
|
end
|
|
164
311
|
end
|
|
165
312
|
|
|
166
|
-
def pick(pool, names)
|
|
167
|
-
return pool if names.nil? || names.empty?
|
|
168
|
-
|
|
169
|
-
by_name = pool.to_h { |tool| [tool.name, tool] }
|
|
170
|
-
names.map do |name|
|
|
171
|
-
by_name.fetch(name) do
|
|
172
|
-
raise ArgumentError,
|
|
173
|
-
"unknown tool #{name.inspect}; available: #{by_name.keys.join(", ")}"
|
|
174
|
-
end
|
|
175
|
-
end
|
|
176
|
-
end
|
|
177
|
-
|
|
178
|
-
def spawner_schema(pool, models, default)
|
|
179
|
-
tool_names = pool.map(&:name)
|
|
180
|
-
fallback = default ? " (default: #{default})" : ""
|
|
181
|
-
lambda do
|
|
182
|
-
string :name, "A short name for this worker, shown wherever its events appear"
|
|
183
|
-
string :task, "The child's complete task", required: true
|
|
184
|
-
string :instructions, "The child's system prompt", required: true
|
|
185
|
-
if tool_names.any?
|
|
186
|
-
array :tools, "Subset of tools to grant (default: all)",
|
|
187
|
-
items: { type: "string", enum: tool_names }
|
|
188
|
-
end
|
|
189
|
-
string :model, "Model for the child#{fallback}", enum: models if models.any?
|
|
190
|
-
end
|
|
191
|
-
end
|
|
192
|
-
|
|
193
|
-
def default_model(provider)
|
|
194
|
-
provider.model if provider.respond_to?(:model)
|
|
195
|
-
end
|
|
196
|
-
|
|
197
|
-
# The label rides origins as "label#id" and joins nesting with ">",
|
|
198
|
-
# so those separators squeeze to hyphens along with whitespace.
|
|
199
|
-
def child_label(raw)
|
|
200
|
-
label = raw.to_s.gsub(/[#>\s]+/, "-").squeeze("-")[0, 32]
|
|
201
|
-
label = label.delete_prefix("-").delete_suffix("-")
|
|
202
|
-
label.empty? ? "spawn" : label
|
|
203
|
-
end
|
|
204
|
-
|
|
205
|
-
def child_provider(default, requested, models)
|
|
206
|
-
return default if requested.nil? || requested.to_s.empty?
|
|
207
|
-
unless models.include?(requested)
|
|
208
|
-
raise ArgumentError,
|
|
209
|
-
"model #{requested.inspect} is not allowed; available: #{models.join(", ")}"
|
|
210
|
-
end
|
|
211
|
-
|
|
212
|
-
Mistri.provider(requested)
|
|
213
|
-
end
|
|
214
|
-
|
|
215
313
|
# A predicate gate cannot be judged statically; the runtime denial in
|
|
216
314
|
# answer covers it.
|
|
217
315
|
def statically_gated?(tool)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Mistri
|
|
6
|
+
# The text side of task mode: how a schema is asked for, how the answer
|
|
7
|
+
# parses, and how a violation is sent back for a fix. Pure functions over
|
|
8
|
+
# strings and schemas; Agent#task owns the loop that drives them.
|
|
9
|
+
module TaskOutput
|
|
10
|
+
# Distinguishable from a parsed nil: JSON "null" is a valid value.
|
|
11
|
+
PARSE_FAILED = Object.new.freeze
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def prompt(input, schema)
|
|
16
|
+
"#{input}\n\nAnswer with ONLY a JSON value matching this schema:\n" \
|
|
17
|
+
"#{JSON.generate(Schema.strict(schema))}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def parse(text)
|
|
21
|
+
body = text.to_s.strip
|
|
22
|
+
body = body[/\A```(?:json)?\s*(.*?)```\z/m, 1] || body
|
|
23
|
+
JSON.parse(body)
|
|
24
|
+
rescue JSON::ParserError
|
|
25
|
+
PARSE_FAILED
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def errors(value, schema)
|
|
29
|
+
return ["the answer is not valid JSON"] if value.equal?(PARSE_FAILED)
|
|
30
|
+
|
|
31
|
+
Schema.violations(value, schema)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def fix_prompt(errors)
|
|
35
|
+
lines = errors.map { |error| "- #{error}" }.join("\n")
|
|
36
|
+
"Your answer did not satisfy the required output schema. Problems:\n" \
|
|
37
|
+
"#{lines}\nReply with ONLY the corrected JSON."
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
data/lib/mistri/tool.rb
CHANGED
|
@@ -28,7 +28,7 @@ module Mistri
|
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
def initialize(name:, description:, input_schema: EMPTY_SCHEMA, eager_input_streaming: false,
|
|
31
|
-
needs_approval: false, timeout: nil, &handler)
|
|
31
|
+
needs_approval: false, ends_turn: false, timeout: nil, &handler)
|
|
32
32
|
raise ArgumentError, "tool #{name.inspect} needs a handler block" unless handler
|
|
33
33
|
|
|
34
34
|
@name = name.to_s
|
|
@@ -36,6 +36,7 @@ module Mistri
|
|
|
36
36
|
@input_schema = input_schema
|
|
37
37
|
@eager_input_streaming = eager_input_streaming
|
|
38
38
|
@needs_approval = needs_approval
|
|
39
|
+
@ends_turn = ends_turn
|
|
39
40
|
@timeout = timeout
|
|
40
41
|
@handler = handler
|
|
41
42
|
end
|
|
@@ -61,6 +62,14 @@ module Mistri
|
|
|
61
62
|
@needs_approval.respond_to?(:call) ? @needs_approval.call(arguments) : @needs_approval
|
|
62
63
|
end
|
|
63
64
|
|
|
65
|
+
# A tool that is the last word of its turn: once it executes, the loop
|
|
66
|
+
# ends the run instead of prompting the model again. This is how a tool
|
|
67
|
+
# like ask_user hands the floor to a human structurally, with no prompt
|
|
68
|
+
# discipline required; the answer arrives as the next run's input.
|
|
69
|
+
def ends_turn?
|
|
70
|
+
@ends_turn
|
|
71
|
+
end
|
|
72
|
+
|
|
64
73
|
# The provider-facing definition; every serializer accepts this shape.
|
|
65
74
|
def spec
|
|
66
75
|
definition = { name: @name, description: @description, input_schema: @input_schema }
|
data/lib/mistri/tool_context.rb
CHANGED
|
@@ -3,14 +3,14 @@
|
|
|
3
3
|
module Mistri
|
|
4
4
|
# What a tool handler may know about the run it executes inside: the
|
|
5
5
|
# caller's session, the abort signal, the event stream, and the host's
|
|
6
|
-
# own context object. Handlers take it as an optional second argument
|
|
6
|
+
# own context object. Handlers take it as an optional second argument: a
|
|
7
7
|
# proc ignores it invisibly, a lambda opts in by accepting two
|
|
8
8
|
# parameters. Sub-agents are built on it; any tool that spawns work,
|
|
9
9
|
# links records to the session, or streams progress can use it the same
|
|
10
10
|
# way.
|
|
11
11
|
#
|
|
12
|
-
# app carries whatever the host passes as Agent.new(context:)
|
|
13
|
-
# acting user, a tenant, a request
|
|
12
|
+
# app carries whatever the host passes as Agent.new(context:) (the
|
|
13
|
+
# acting user, a tenant, a request), untouched. The gem provides the
|
|
14
14
|
# slot, never the vocabulary:
|
|
15
15
|
#
|
|
16
16
|
# agent = Mistri.agent("claude-opus-4-8", tools: tools,
|
data/lib/mistri/version.rb
CHANGED
data/lib/mistri.rb
CHANGED
|
@@ -14,6 +14,7 @@ require_relative "mistri/sse"
|
|
|
14
14
|
require_relative "mistri/partial_json"
|
|
15
15
|
require_relative "mistri/transport"
|
|
16
16
|
require_relative "mistri/schema"
|
|
17
|
+
require_relative "mistri/task_output"
|
|
17
18
|
require_relative "mistri/edit"
|
|
18
19
|
require_relative "mistri/tool_context"
|
|
19
20
|
require_relative "mistri/tool_result"
|
|
@@ -34,10 +35,15 @@ require_relative "mistri/compaction"
|
|
|
34
35
|
require_relative "mistri/stores/memory"
|
|
35
36
|
require_relative "mistri/stores/jsonl"
|
|
36
37
|
require_relative "mistri/session"
|
|
38
|
+
require_relative "mistri/child"
|
|
39
|
+
require_relative "mistri/locks"
|
|
40
|
+
require_relative "mistri/console"
|
|
41
|
+
require_relative "mistri/dispatchers"
|
|
37
42
|
require_relative "mistri/compactor"
|
|
38
43
|
require_relative "mistri/result"
|
|
39
44
|
require_relative "mistri/agent"
|
|
40
45
|
require_relative "mistri/sub_agent"
|
|
46
|
+
require_relative "mistri/spawner"
|
|
41
47
|
require_relative "mistri/mcp"
|
|
42
48
|
require_relative "mistri/sinks/action_cable"
|
|
43
49
|
require_relative "mistri/sinks/sse"
|
|
@@ -54,6 +60,11 @@ module Mistri
|
|
|
54
60
|
API_KEY_ENV = { anthropic: "ANTHROPIC_API_KEY", openai: "OPENAI_API_KEY",
|
|
55
61
|
gemini: "GEMINI_API_KEY" }.freeze
|
|
56
62
|
|
|
63
|
+
# The configured lock adapter, nil until a host sets one. See Locks.
|
|
64
|
+
class << self
|
|
65
|
+
attr_accessor :locks
|
|
66
|
+
end
|
|
67
|
+
|
|
57
68
|
module_function
|
|
58
69
|
|
|
59
70
|
# Build a provider for a model, inferring which one from the model id and
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mistri
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Muhammad Ahmed Cheema
|
|
@@ -31,13 +31,18 @@ files:
|
|
|
31
31
|
- lib/mistri/abort_signal.rb
|
|
32
32
|
- lib/mistri/agent.rb
|
|
33
33
|
- lib/mistri/budget.rb
|
|
34
|
+
- lib/mistri/child.rb
|
|
34
35
|
- lib/mistri/compaction.rb
|
|
35
36
|
- lib/mistri/compactor.rb
|
|
37
|
+
- lib/mistri/console.rb
|
|
36
38
|
- lib/mistri/content.rb
|
|
37
39
|
- lib/mistri/definition.rb
|
|
40
|
+
- lib/mistri/dispatchers.rb
|
|
38
41
|
- lib/mistri/edit.rb
|
|
39
42
|
- lib/mistri/errors.rb
|
|
40
43
|
- lib/mistri/event.rb
|
|
44
|
+
- lib/mistri/locks.rb
|
|
45
|
+
- lib/mistri/locks/rails_cache.rb
|
|
41
46
|
- lib/mistri/mcp.rb
|
|
42
47
|
- lib/mistri/mcp/client.rb
|
|
43
48
|
- lib/mistri/mcp/oauth.rb
|
|
@@ -66,12 +71,14 @@ files:
|
|
|
66
71
|
- lib/mistri/sinks/sse.rb
|
|
67
72
|
- lib/mistri/skill.rb
|
|
68
73
|
- lib/mistri/skills.rb
|
|
74
|
+
- lib/mistri/spawner.rb
|
|
69
75
|
- lib/mistri/sse.rb
|
|
70
76
|
- lib/mistri/stop_reason.rb
|
|
71
77
|
- lib/mistri/stores/active_record.rb
|
|
72
78
|
- lib/mistri/stores/jsonl.rb
|
|
73
79
|
- lib/mistri/stores/memory.rb
|
|
74
80
|
- lib/mistri/sub_agent.rb
|
|
81
|
+
- lib/mistri/task_output.rb
|
|
75
82
|
- lib/mistri/tool.rb
|
|
76
83
|
- lib/mistri/tool_call.rb
|
|
77
84
|
- lib/mistri/tool_context.rb
|