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
|
@@ -4,8 +4,10 @@ module Mistri
|
|
|
4
4
|
module Sinks
|
|
5
5
|
# Wraps any sink and merges bursts of streaming deltas, so a transport
|
|
6
6
|
# broadcasts at UI speed instead of token speed. Deltas buffer per
|
|
7
|
-
# content block and flush merged when the interval elapses or
|
|
8
|
-
# event arrives, so ordering is preserved and a turn always
|
|
7
|
+
# content block per origin and flush merged when the interval elapses or
|
|
8
|
+
# any other event arrives, so ordering is preserved and a turn always
|
|
9
|
+
# ends flushed. Safe to share across threads: a background worker's
|
|
10
|
+
# events serialize with the parent's instead of racing the buffer.
|
|
9
11
|
#
|
|
10
12
|
# sink = Mistri::Sinks::Coalesced.new(
|
|
11
13
|
# Mistri::Sinks::ActionCable.new("agent_1"), interval: 0.1,
|
|
@@ -18,16 +20,19 @@ module Mistri
|
|
|
18
20
|
@interval = interval
|
|
19
21
|
@buffer = nil
|
|
20
22
|
@flushed_at = now
|
|
23
|
+
@mutex = Mutex.new
|
|
21
24
|
end
|
|
22
25
|
|
|
23
26
|
def call(event)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
+
@mutex.synchronize do
|
|
28
|
+
if DELTAS.include?(event.type)
|
|
29
|
+
merge(event)
|
|
30
|
+
flush if now - @flushed_at >= @interval
|
|
31
|
+
else
|
|
32
|
+
flush
|
|
33
|
+
@sink.call(event)
|
|
34
|
+
end
|
|
27
35
|
end
|
|
28
|
-
|
|
29
|
-
merge(event)
|
|
30
|
-
flush if now - @flushed_at >= @interval
|
|
31
36
|
end
|
|
32
37
|
|
|
33
38
|
def to_proc = method(:call).to_proc
|
|
@@ -35,10 +40,12 @@ module Mistri
|
|
|
35
40
|
private
|
|
36
41
|
|
|
37
42
|
# Merged deltas keep the newest partial, so a consumer that renders
|
|
38
|
-
# snapshots always renders the latest one.
|
|
43
|
+
# snapshots always renders the latest one. Origin is part of a block's
|
|
44
|
+
# identity: two agents' lanes never fold into one event.
|
|
39
45
|
def merge(event)
|
|
40
46
|
same = @buffer && @buffer.type == event.type &&
|
|
41
|
-
@buffer.content_index == event.content_index
|
|
47
|
+
@buffer.content_index == event.content_index &&
|
|
48
|
+
@buffer.origin == event.origin
|
|
42
49
|
flush if @buffer && !same
|
|
43
50
|
@buffer = if same
|
|
44
51
|
@buffer.with(delta: @buffer.delta.to_s + event.delta.to_s,
|
data/lib/mistri/skill.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Mistri
|
|
4
4
|
# One expert playbook: a name the model selects by, a description that
|
|
5
5
|
# earns the selection, and the full body it reads before acting. Build
|
|
6
|
-
# them from anywhere
|
|
6
|
+
# them from anywhere: Skills.load reads a directory, and a host with
|
|
7
7
|
# skills in a database constructs these directly.
|
|
8
8
|
Skill = Data.define(:name, :description, :body) do
|
|
9
9
|
def initialize(name:, description: "", body: "")
|
data/lib/mistri/skills.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Mistri
|
|
4
4
|
# Loads skills and wires them into an agent: their one-line descriptions
|
|
5
5
|
# ride the system prompt, and the model pulls a full body on demand with
|
|
6
|
-
# the read_skill tool
|
|
6
|
+
# the read_skill tool, so a large library costs almost nothing until a
|
|
7
7
|
# skill is actually used.
|
|
8
8
|
module Skills
|
|
9
9
|
module_function
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
# Host policy for spawning workers, as one object: the tool pool children
|
|
5
|
+
# may draw from, the curated types, the model allowlist, the headcount
|
|
6
|
+
# cap, and the dispatcher that makes background mode real. A host runtime
|
|
7
|
+
# factory reconstructs live dependencies after the dispatch boundary;
|
|
8
|
+
# model text never chooses or proves resource isolation. #tool builds the
|
|
9
|
+
# spawn_agent tool the top-level agent holds; SubAgent.spawner and
|
|
10
|
+
# SubAgent.pack are the front doors.
|
|
11
|
+
#
|
|
12
|
+
# Every policy violation answers the model in band (unknown type, missing
|
|
13
|
+
# instructions, over capacity); only host configuration mistakes raise,
|
|
14
|
+
# and they raise at construction.
|
|
15
|
+
class Spawner
|
|
16
|
+
def initialize(provider:, tools: [], types: {}, models: [], max_children: 4,
|
|
17
|
+
dispatcher: nil, runtime_factory: nil, needs_approval: false,
|
|
18
|
+
**agent_options)
|
|
19
|
+
tools = Array.new(tools) if tools.is_a?(Array)
|
|
20
|
+
models = Array.new(models) if models.is_a?(Array)
|
|
21
|
+
ChildAgentOptions.validate!(agent_options)
|
|
22
|
+
validate_configuration!(tools, models, dispatcher, runtime_factory)
|
|
23
|
+
|
|
24
|
+
@provider = provider
|
|
25
|
+
@pool = tools.freeze
|
|
26
|
+
# Symbol keys are natural Ruby; the wire speaks strings. One
|
|
27
|
+
# normalization here and lookup, schema, and menu all agree.
|
|
28
|
+
@types = types.transform_keys(&:to_s).freeze
|
|
29
|
+
@models = models.map { |model| String.new(model).freeze }.freeze
|
|
30
|
+
@max_children = max_children
|
|
31
|
+
@dispatcher = dispatcher
|
|
32
|
+
@runtime_factory = runtime_factory
|
|
33
|
+
@needs_approval = needs_approval
|
|
34
|
+
@agent_options = agent_options
|
|
35
|
+
validate_types!
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def tool
|
|
39
|
+
spawner = self
|
|
40
|
+
Tool.define("spawn_agent", description, needs_approval: @needs_approval,
|
|
41
|
+
schema: schema) do |args, context|
|
|
42
|
+
spawner.spawn(args, context)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def spawn(args, context)
|
|
47
|
+
crowded = over_capacity(context.session)
|
|
48
|
+
return crowded if crowded
|
|
49
|
+
|
|
50
|
+
worker = resolve_worker(args)
|
|
51
|
+
return worker if worker.is_a?(String)
|
|
52
|
+
|
|
53
|
+
return dispatch(args, worker, context) if args["mode"] == "background" && @dispatcher
|
|
54
|
+
|
|
55
|
+
SubAgent.run_child(label: label_for(args), provider: provider_for(worker),
|
|
56
|
+
system: worker[:system], tools: worker[:tools],
|
|
57
|
+
task: args.fetch("task"), parent_context: context,
|
|
58
|
+
agent_options: @agent_options)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def validate_configuration!(tools, models, dispatcher, runtime_factory)
|
|
64
|
+
validate_tool_pool!(tools)
|
|
65
|
+
validate_models!(models)
|
|
66
|
+
validate_dispatcher!(dispatcher, runtime_factory)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def validate_tool_pool!(tools)
|
|
70
|
+
unless tools.is_a?(Array) && tools.all?(Tool)
|
|
71
|
+
raise ConfigurationError, "the spawn tool pool must contain only Mistri::Tool instances"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
SubAgent.forbid_gated!(tools)
|
|
75
|
+
if tools.any? { |tool| tool.name == "spawn_agent" }
|
|
76
|
+
raise ConfigurationError, "the spawn tool never goes in its own pool"
|
|
77
|
+
end
|
|
78
|
+
return if tools.map(&:name).uniq.length == tools.length
|
|
79
|
+
|
|
80
|
+
raise ConfigurationError, "the spawn tool pool has duplicate tool names"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def validate_models!(models)
|
|
84
|
+
unless models.is_a?(Array) &&
|
|
85
|
+
models.all? { |model| model.is_a?(String) && !model.empty? }
|
|
86
|
+
raise ConfigurationError, "the spawn model allowlist must contain non-empty strings"
|
|
87
|
+
end
|
|
88
|
+
return if models.uniq.length == models.length
|
|
89
|
+
|
|
90
|
+
raise ConfigurationError, "the spawn model allowlist has duplicate model names"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def validate_dispatcher!(dispatcher, runtime_factory)
|
|
94
|
+
if dispatcher && !dispatcher.respond_to?(:call)
|
|
95
|
+
raise ConfigurationError, "dispatcher must be callable"
|
|
96
|
+
end
|
|
97
|
+
if dispatcher && !runtime_factory.respond_to?(:call)
|
|
98
|
+
raise ConfigurationError,
|
|
99
|
+
"a dispatcher requires runtime_factory: to reconstruct background children"
|
|
100
|
+
end
|
|
101
|
+
return unless runtime_factory && !dispatcher
|
|
102
|
+
|
|
103
|
+
raise ConfigurationError, "runtime_factory: requires a dispatcher"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Create the child session and its lifecycle entries, hand the
|
|
107
|
+
# dispatcher the serializable spec plus an in-process runner that calls
|
|
108
|
+
# the host factory inside the worker, and answer with a truthful receipt:
|
|
109
|
+
# what the child's status says after dispatch, not what the mode
|
|
110
|
+
# promised. The runner closes over the spawn-time emit, so in-process
|
|
111
|
+
# dispatchers keep streaming to whoever watched the spawn.
|
|
112
|
+
def dispatch(args, worker, context)
|
|
113
|
+
store = context.session ? context.session.store : Stores::Memory.new
|
|
114
|
+
child = Session.new(store: store)
|
|
115
|
+
label = label_for(args)
|
|
116
|
+
spec = spec_for(args, worker, child, label, context)
|
|
117
|
+
context.session&.append("subagent", "name" => label, "session_id" => child.id)
|
|
118
|
+
child.append(Child::DISPATCHED, "spec" => spec)
|
|
119
|
+
emit = context.emit
|
|
120
|
+
runtime_factory = @runtime_factory
|
|
121
|
+
runner = lambda do
|
|
122
|
+
SubAgent.run_dispatched(spec, runtime_factory: runtime_factory,
|
|
123
|
+
store: store, emit: emit,
|
|
124
|
+
retry_factory_errors: false)
|
|
125
|
+
end
|
|
126
|
+
@dispatcher.call(spec, runner)
|
|
127
|
+
receipt(label, child, store)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def spec_for(args, worker, child, label, context)
|
|
131
|
+
model = worker[:model]
|
|
132
|
+
unless model.is_a?(String) && !model.empty?
|
|
133
|
+
raise ConfigurationError,
|
|
134
|
+
"a background child provider must expose a non-empty model identity"
|
|
135
|
+
end
|
|
136
|
+
spec = { "spec_version" => SubAgent::DISPATCH_SPEC_VERSION,
|
|
137
|
+
"name" => label, "session_id" => child.id,
|
|
138
|
+
"parent_session_id" => context.session&.id,
|
|
139
|
+
"type" => args["type"] || "general-purpose",
|
|
140
|
+
"instructions" => args["instructions"], "task" => args.fetch("task"),
|
|
141
|
+
"tool_names" => worker[:tools].map(&:name),
|
|
142
|
+
"model" => model }
|
|
143
|
+
owned, error = ToolArguments.canonicalize(spec)
|
|
144
|
+
return owned unless error
|
|
145
|
+
|
|
146
|
+
raise ConfigurationError, "background child spec is not bounded JSON"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def receipt(label, child, store)
|
|
150
|
+
status = Child.new(name: label, session_id: child.id, store: store).status
|
|
151
|
+
state = if %i[running queued].include?(status)
|
|
152
|
+
"is working in the background"
|
|
153
|
+
else
|
|
154
|
+
"already finished (#{status})"
|
|
155
|
+
end
|
|
156
|
+
ToolResult.new(
|
|
157
|
+
content: "#{label} #{state} (agent id #{child.id[0, 8]}). Keep working: its " \
|
|
158
|
+
"report will arrive in your context when it finishes. Meanwhile " \
|
|
159
|
+
"read_agent checks on it (wait: true blocks for the report), " \
|
|
160
|
+
"steer_agent adjusts it, stop_agent stops it.",
|
|
161
|
+
ui: { "agent" => label, "session_id" => child.id, "mode" => "background" }
|
|
162
|
+
)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# A worker's system prompt, tools, and provider, resolved from its
|
|
166
|
+
# type; a String answers the model in band instead of raising.
|
|
167
|
+
def resolve_worker(args)
|
|
168
|
+
type = args["type"].to_s
|
|
169
|
+
return general_worker(args) if type.empty? || type == "general-purpose"
|
|
170
|
+
|
|
171
|
+
definition = @types[type]
|
|
172
|
+
unless definition
|
|
173
|
+
return "Unknown worker type #{type.inspect}; available: " \
|
|
174
|
+
"#{["general-purpose", *@types.keys].join(", ")}."
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
system = [definition.render, args["instructions"]]
|
|
178
|
+
.reject { |part| part.to_s.strip.empty? }.join("\n\n")
|
|
179
|
+
chosen = args["tools"].nil? ? definition.tool_names : args["tools"]
|
|
180
|
+
{ system: system, tools: pick(chosen),
|
|
181
|
+
**provider_choice(args["model"], definition.model) }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def general_worker(args)
|
|
185
|
+
system = args["instructions"].to_s
|
|
186
|
+
if system.strip.empty?
|
|
187
|
+
return "A general-purpose worker needs instructions: write its system prompt, " \
|
|
188
|
+
"or pick a type."
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
{ system: system, tools: pick(args["tools"]), **provider_choice(args["model"]) }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# A background grant needs only a stable model identity; constructing
|
|
195
|
+
# its live provider belongs to the worker factory. Inline execution turns
|
|
196
|
+
# the same choice into a provider immediately before the child starts.
|
|
197
|
+
def provider_choice(requested, definition_model = nil)
|
|
198
|
+
if requested.nil? || requested.to_s.empty?
|
|
199
|
+
return { model: definition_model } unless definition_model.to_s.empty?
|
|
200
|
+
|
|
201
|
+
model = @provider.model if @provider.respond_to?(:model)
|
|
202
|
+
return { provider: @provider, model: model }
|
|
203
|
+
end
|
|
204
|
+
unless @models.include?(requested)
|
|
205
|
+
raise ArgumentError,
|
|
206
|
+
"model #{requested.inspect} is not allowed; available: #{@models.join(", ")}"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
{ model: requested }
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def provider_for(worker)
|
|
213
|
+
worker[:provider] || Mistri.provider(worker.fetch(:model))
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def over_capacity(session)
|
|
217
|
+
return nil unless session
|
|
218
|
+
|
|
219
|
+
busy = session.children.count { |child| Child::LIVE.include?(child.status) }
|
|
220
|
+
return nil if busy < @max_children
|
|
221
|
+
|
|
222
|
+
"You already have #{busy} workers running; wait for one to finish or stop one."
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Types fail at construction, never mid-spawn: every definition must
|
|
226
|
+
# render without vars (spawn types are self-contained prompts) and
|
|
227
|
+
# declare only tools the pool actually carries.
|
|
228
|
+
def validate_types!
|
|
229
|
+
return if @types.empty?
|
|
230
|
+
if @types.key?("general-purpose")
|
|
231
|
+
raise ConfigurationError, "\"general-purpose\" is the built-in type; pick another name"
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
pool_names = @pool.map(&:name)
|
|
235
|
+
@types.each do |name, definition|
|
|
236
|
+
begin
|
|
237
|
+
definition.render
|
|
238
|
+
rescue ConfigurationError => e
|
|
239
|
+
raise ConfigurationError,
|
|
240
|
+
"type #{name.inspect} cannot be a spawn type: #{e.message}"
|
|
241
|
+
end
|
|
242
|
+
missing = definition.tool_names - pool_names
|
|
243
|
+
unless missing.empty?
|
|
244
|
+
raise ConfigurationError,
|
|
245
|
+
"type #{name.inspect} declares tools the pool lacks: #{missing.join(", ")}"
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def pick(names)
|
|
251
|
+
return @pool if names.nil?
|
|
252
|
+
if names.uniq.length != names.length
|
|
253
|
+
raise ArgumentError, "tool selections cannot contain duplicate names"
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
by_name = @pool.to_h { |tool| [tool.name, tool] }
|
|
257
|
+
names.map do |name|
|
|
258
|
+
by_name.fetch(name) do
|
|
259
|
+
raise ArgumentError,
|
|
260
|
+
"unknown tool #{name.inspect}; available: #{by_name.keys.join(", ")}"
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def schema
|
|
266
|
+
pool_names = @pool.map(&:name)
|
|
267
|
+
type_names = ["general-purpose", *@types.keys]
|
|
268
|
+
types = @types
|
|
269
|
+
models = @models
|
|
270
|
+
dispatcher = @dispatcher
|
|
271
|
+
default = @provider.model if @provider.respond_to?(:model)
|
|
272
|
+
fallback = default ? " (default: #{default})" : ""
|
|
273
|
+
lambda do
|
|
274
|
+
string :name, "A short name for this worker, shown wherever its events appear"
|
|
275
|
+
string :task, "The child's complete task", required: true
|
|
276
|
+
if types.any?
|
|
277
|
+
string :type, "The kind of worker (default: general-purpose)", enum: type_names
|
|
278
|
+
string :instructions, "The worker's system prompt (required for " \
|
|
279
|
+
"general-purpose; appended to a typed worker's own)"
|
|
280
|
+
else
|
|
281
|
+
string :instructions, "The child's system prompt", required: true
|
|
282
|
+
end
|
|
283
|
+
if pool_names.any?
|
|
284
|
+
array :tools, "Exact tools to grant (omit for all, or the type's own list; " \
|
|
285
|
+
"an empty list grants none)",
|
|
286
|
+
items: { type: "string", enum: pool_names }, uniqueItems: true
|
|
287
|
+
end
|
|
288
|
+
string :model, "Model for the child#{fallback}", enum: models if models.any?
|
|
289
|
+
if dispatcher
|
|
290
|
+
string :mode, "inline blocks until the report; background returns a receipt " \
|
|
291
|
+
"now and you keep working (default: inline)",
|
|
292
|
+
enum: %w[inline background]
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def description
|
|
298
|
+
text = SubAgent::SPAWNER_DESCRIPTION
|
|
299
|
+
unless @types.empty?
|
|
300
|
+
text += " Typed workers come ready-made (#{@types.keys.join(", ")}): their " \
|
|
301
|
+
"instructions, tools, and model are set; add instructions only to " \
|
|
302
|
+
"focus them. general-purpose workers are yours to compose."
|
|
303
|
+
end
|
|
304
|
+
if @dispatcher
|
|
305
|
+
text += " Use background mode when the work is long and you can keep helping " \
|
|
306
|
+
"meanwhile: you get a receipt now, its report arrives on its own when " \
|
|
307
|
+
"the worker finishes, and the console tools manage it in between."
|
|
308
|
+
end
|
|
309
|
+
text
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def label_for(args)
|
|
313
|
+
SubAgent.sanitize_label(args["name"], fallback: "spawn")
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
data/lib/mistri/sse.rb
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
|
|
5
5
|
module Mistri
|
|
6
|
+
DEFAULT_MAX_RECORD_BYTES = 8 * 1024 * 1024
|
|
7
|
+
private_constant :DEFAULT_MAX_RECORD_BYTES
|
|
8
|
+
|
|
6
9
|
# An incremental Server-Sent Events decoder. Feed it raw socket fragments in
|
|
7
10
|
# any chunking; it buffers partial lines across fragments and yields each
|
|
8
11
|
# complete "data:" record as a parsed Hash.
|
|
@@ -11,40 +14,80 @@ module Mistri
|
|
|
11
14
|
# send: one single-line JSON object per "data:" record. "event:", "id:",
|
|
12
15
|
# comment, and blank lines are ignored (the event name is duplicated inside
|
|
13
16
|
# the data payload), OpenAI's "[DONE]" sentinel is dropped, and a record that
|
|
14
|
-
# fails to parse is skipped rather than killing a live stream.
|
|
17
|
+
# fails to parse is skipped rather than killing a live stream. Only the
|
|
18
|
+
# partial line is bounded; the complete stream is not.
|
|
15
19
|
class SSE
|
|
16
|
-
def initialize
|
|
20
|
+
def initialize(max_record_bytes: DEFAULT_MAX_RECORD_BYTES)
|
|
21
|
+
unless max_record_bytes.is_a?(Integer) && max_record_bytes.positive?
|
|
22
|
+
raise ConfigurationError, "max_record_bytes: must be a positive integer"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
@max_record_bytes = max_record_bytes
|
|
17
26
|
@buffer = +""
|
|
18
27
|
end
|
|
19
28
|
|
|
20
29
|
def feed(fragment, &)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
30
|
+
scan = fragment.encoding == Encoding::BINARY ? fragment : fragment.b
|
|
31
|
+
offset = 0
|
|
32
|
+
while (newline = scan.index("\n", offset))
|
|
33
|
+
append(fragment, offset, newline - offset)
|
|
34
|
+
line = @buffer
|
|
35
|
+
@buffer = +""
|
|
36
|
+
line.chomp!
|
|
37
|
+
decode(line, &)
|
|
38
|
+
offset = newline + 1
|
|
25
39
|
end
|
|
40
|
+
append(fragment, offset, scan.bytesize - offset)
|
|
26
41
|
nil
|
|
27
42
|
end
|
|
28
43
|
|
|
29
44
|
# Flush a trailing record that arrived without a final newline.
|
|
30
45
|
def finish(&)
|
|
31
|
-
|
|
32
|
-
|
|
46
|
+
unless @buffer.empty?
|
|
47
|
+
line = @buffer
|
|
48
|
+
@buffer = +""
|
|
49
|
+
line.chomp!
|
|
50
|
+
decode(line, &)
|
|
51
|
+
end
|
|
33
52
|
nil
|
|
34
53
|
end
|
|
35
54
|
|
|
36
55
|
private
|
|
37
56
|
|
|
57
|
+
def append(fragment, offset, length)
|
|
58
|
+
return if length.zero?
|
|
59
|
+
|
|
60
|
+
if @buffer.bytesize + length > @max_record_bytes
|
|
61
|
+
raise ResponseTooLargeError.new(kind: :sse_line, limit: @max_record_bytes)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
@buffer << fragment.byteslice(offset, length)
|
|
65
|
+
end
|
|
66
|
+
|
|
38
67
|
def decode(line)
|
|
39
|
-
return unless line.
|
|
68
|
+
return unless line.delete_prefix!("data:")
|
|
69
|
+
|
|
70
|
+
line.strip!
|
|
71
|
+
return if line.empty? || line == "[DONE]"
|
|
40
72
|
|
|
41
|
-
|
|
42
|
-
|
|
73
|
+
case ToolArguments.raw_json_resource_error(line)
|
|
74
|
+
when "too_many_nodes"
|
|
75
|
+
raise ResponseTooComplexError.new(kind: :sse_record_tokens,
|
|
76
|
+
limit: ToolArguments::MAX_LEXICAL_TOKENS)
|
|
77
|
+
when "too_deep"
|
|
78
|
+
raise ResponseTooComplexError.new(kind: :sse_record_depth,
|
|
79
|
+
limit: ToolArguments::MAX_DEPTH)
|
|
80
|
+
when "number_too_large"
|
|
81
|
+
raise ResponseTooComplexError.new(kind: :sse_numeric_token,
|
|
82
|
+
limit: ToolArguments::MAX_NUMBER_BYTES)
|
|
83
|
+
end
|
|
43
84
|
|
|
44
|
-
decoded =
|
|
85
|
+
decoded = begin
|
|
86
|
+
JSON.parse(line)
|
|
87
|
+
rescue JSON::ParserError
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
45
90
|
yield decoded if decoded.is_a?(Hash)
|
|
46
|
-
rescue JSON::ParserError
|
|
47
|
-
nil
|
|
48
91
|
end
|
|
49
92
|
end
|
|
50
93
|
end
|
|
@@ -15,26 +15,41 @@ module Mistri
|
|
|
15
15
|
# create_table :mistri_entries do |t|
|
|
16
16
|
# t.string :session_id, null: false, index: true
|
|
17
17
|
# t.integer :position, null: false
|
|
18
|
-
# t.text :payload, size: :
|
|
18
|
+
# t.text :payload, size: :long, null: false
|
|
19
19
|
# t.timestamps
|
|
20
20
|
# end
|
|
21
21
|
# add_index :mistri_entries, [:session_id, :position], unique: true
|
|
22
22
|
#
|
|
23
|
-
# The unique index is load-bearing:
|
|
24
|
-
#
|
|
25
|
-
#
|
|
23
|
+
# The unique index is load-bearing: a session's loop is serial, but other
|
|
24
|
+
# writers append alongside it by design (a steer from a web process, a
|
|
25
|
+
# background child's report from a job), so appends are optimistic. Two
|
|
26
|
+
# writers that pick the same position collide on the index and the loser
|
|
27
|
+
# retries at the next one; entry order stays intact without a lock.
|
|
26
28
|
#
|
|
27
29
|
# Reads select only (position, payload) and sort in Ruby: ORDER BY over
|
|
28
30
|
# rows carrying multi-megabyte payloads exhausts MySQL's sort buffer.
|
|
29
31
|
class ActiveRecord
|
|
32
|
+
# Concurrent writers converge in one or two retries; a session would
|
|
33
|
+
# need this many simultaneous appenders to exhaust them.
|
|
34
|
+
APPEND_ATTEMPTS = 5
|
|
35
|
+
|
|
30
36
|
def initialize(model)
|
|
31
37
|
@model = model
|
|
32
38
|
end
|
|
33
39
|
|
|
34
40
|
def append(id, entry)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
41
|
+
payload = JSON.generate(entry)
|
|
42
|
+
attempts = 0
|
|
43
|
+
begin
|
|
44
|
+
position = @model.where(session_id: id).maximum(:position).to_i + 1
|
|
45
|
+
@model.create!(session_id: id, position: position, payload: payload)
|
|
46
|
+
nil
|
|
47
|
+
rescue ::ActiveRecord::RecordNotUnique
|
|
48
|
+
attempts += 1
|
|
49
|
+
raise if attempts >= APPEND_ATTEMPTS
|
|
50
|
+
|
|
51
|
+
retry
|
|
52
|
+
end
|
|
38
53
|
end
|
|
39
54
|
|
|
40
55
|
def load(id)
|
data/lib/mistri/stores/jsonl.rb
CHANGED
|
@@ -13,8 +13,10 @@ module Mistri
|
|
|
13
13
|
FileUtils.mkdir_p(dir)
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
# One write call per line: concurrent appenders (a steer, a child's
|
|
17
|
+
# report) interleave whole lines, never fragments.
|
|
16
18
|
def append(id, entry)
|
|
17
|
-
File.
|
|
19
|
+
File.write(path(id), "#{JSON.generate(entry)}\n", mode: "a")
|
|
18
20
|
nil
|
|
19
21
|
end
|
|
20
22
|
|
data/lib/mistri/stores/memory.rb
CHANGED
|
@@ -10,12 +10,31 @@ module Mistri
|
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
def append(id, entry)
|
|
13
|
-
@mutex.synchronize { @entries[id] << entry }
|
|
13
|
+
@mutex.synchronize { @entries[id] << json_copy(entry, freeze: true) }
|
|
14
14
|
nil
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def load(id)
|
|
18
|
-
@mutex.synchronize
|
|
18
|
+
@mutex.synchronize do
|
|
19
|
+
@entries[id].map { |entry| json_copy(entry, freeze: false) }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
# Memory must preserve the same value ownership as stores that decode
|
|
26
|
+
# every read; otherwise a transcript reader can rewrite durable history.
|
|
27
|
+
def json_copy(value, freeze:)
|
|
28
|
+
owned = case value
|
|
29
|
+
when Hash
|
|
30
|
+
value.to_h do |key, nested|
|
|
31
|
+
[json_copy(key, freeze:), json_copy(nested, freeze:)]
|
|
32
|
+
end
|
|
33
|
+
when Array then value.map { |nested| json_copy(nested, freeze:) }
|
|
34
|
+
when String then value.dup
|
|
35
|
+
else value
|
|
36
|
+
end
|
|
37
|
+
freeze ? owned.freeze : owned
|
|
19
38
|
end
|
|
20
39
|
end
|
|
21
40
|
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
class SubAgent
|
|
5
|
+
class << self
|
|
6
|
+
# The hardened execution path every child uses. Inline work acquires
|
|
7
|
+
# and cleans up its lease here; a dispatched owner supplies its lease
|
|
8
|
+
# and retains it through terminal persistence and parent reporting.
|
|
9
|
+
def execute_child(child:, label:, provider:, system:, tools:, task:, schema:,
|
|
10
|
+
signal:, emit:, lease: nil, started: false, agent_options: {})
|
|
11
|
+
ChildAgentOptions.validate!(agent_options)
|
|
12
|
+
child.append(Child::STARTED, {}) unless started
|
|
13
|
+
owns_lease = lease.nil?
|
|
14
|
+
primary_error = nil
|
|
15
|
+
begin
|
|
16
|
+
lease ||= Locks.hold(Child.lease_key(child.id),
|
|
17
|
+
stop_key: Child.stop_key(child.id), signal: signal)
|
|
18
|
+
abort_before_child_start!(child, signal, lease)
|
|
19
|
+
if signal.aborted?
|
|
20
|
+
Result.new(message: nil, status: :aborted)
|
|
21
|
+
else
|
|
22
|
+
run_child_agent(child:, label:, provider:, system:, tools:, task:, schema:,
|
|
23
|
+
signal:, emit:, agent_options:)
|
|
24
|
+
end
|
|
25
|
+
rescue StandardError => e
|
|
26
|
+
primary_error = e
|
|
27
|
+
reported = EventDelivery.original(e)
|
|
28
|
+
child.append(Child::TERMINAL, "status" => "failed",
|
|
29
|
+
"error" => "#{reported.class}: #{reported.message}")
|
|
30
|
+
raise
|
|
31
|
+
ensure
|
|
32
|
+
cleanup_error = release_inline_lease(child, lease) if owns_lease
|
|
33
|
+
raise cleanup_error if cleanup_error && primary_error.nil?
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def release_inline_lease(child, lease)
|
|
38
|
+
errors = []
|
|
39
|
+
begin
|
|
40
|
+
lease&.release
|
|
41
|
+
rescue StandardError => e
|
|
42
|
+
errors << e
|
|
43
|
+
end
|
|
44
|
+
begin
|
|
45
|
+
Mistri.locks&.clear_flag(Child.stop_key(child.id))
|
|
46
|
+
rescue StandardError => e
|
|
47
|
+
errors << e
|
|
48
|
+
end
|
|
49
|
+
errors.first
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def abort_before_child_start!(child, signal, lease)
|
|
53
|
+
return unless Mistri.locks
|
|
54
|
+
return if lease && !Mistri.locks.flag?(Child.stop_key(child.id))
|
|
55
|
+
|
|
56
|
+
signal.abort!("stopped by user")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def run_child_agent(child:, label:, provider:, system:, tools:, task:, schema:,
|
|
60
|
+
signal:, emit:, agent_options:)
|
|
61
|
+
agent = Agent.new(provider: provider, session: child, system: system,
|
|
62
|
+
tools: tools, **agent_options)
|
|
63
|
+
origin = "#{label}##{child.id[0, 8]}"
|
|
64
|
+
tagged = ->(event) { forward(event, origin, emit) }
|
|
65
|
+
return agent.task(task, schema: schema, signal: signal, &tagged) if schema
|
|
66
|
+
|
|
67
|
+
agent.run(task, signal: signal, &tagged)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def forward(event, origin, emit)
|
|
71
|
+
return unless emit
|
|
72
|
+
|
|
73
|
+
tagged = event.origin ? "#{origin}>#{event.origin}" : origin
|
|
74
|
+
emit.call(event.with(origin: tagged))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private :abort_before_child_start!, :execute_child, :forward,
|
|
78
|
+
:release_inline_lease, :run_child_agent
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|