mistri 0.5.0 → 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 +469 -4
- data/CONTRIBUTING.md +52 -0
- data/README.md +289 -385
- 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/agent.rb +575 -55
- data/lib/mistri/budget.rb +26 -1
- data/lib/mistri/child.rb +72 -16
- data/lib/mistri/compaction.rb +26 -10
- data/lib/mistri/compactor.rb +34 -11
- data/lib/mistri/console.rb +28 -7
- data/lib/mistri/content.rb +9 -3
- data/lib/mistri/dispatchers.rb +14 -12
- data/lib/mistri/errors.rb +83 -4
- data/lib/mistri/event.rb +24 -8
- data/lib/mistri/event_delivery.rb +60 -0
- data/lib/mistri/locks.rb +3 -3
- 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 +42 -8
- 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 +26 -10
- 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 +1 -1
- data/lib/mistri/schema.rb +893 -75
- data/lib/mistri/session.rb +560 -48
- data/lib/mistri/sinks/coalesced.rb +17 -10
- data/lib/mistri/spawner.rb +111 -61
- data/lib/mistri/sse.rb +57 -14
- data/lib/mistri/stores/active_record.rb +1 -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 +124 -87
- data/lib/mistri/task_output.rb +24 -6
- data/lib/mistri/tool.rb +93 -13
- data/lib/mistri/tool_arguments.rb +377 -0
- data/lib/mistri/tool_call.rb +43 -9
- data/lib/mistri/tool_context.rb +4 -2
- 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 +6 -1
- data/mistri.gemspec +34 -0
- metadata +31 -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/spawner.rb
CHANGED
|
@@ -3,29 +3,33 @@
|
|
|
3
3
|
module Mistri
|
|
4
4
|
# Host policy for spawning workers, as one object: the tool pool children
|
|
5
5
|
# may draw from, the curated types, the model allowlist, the headcount
|
|
6
|
-
# cap, and the dispatcher that makes background mode real.
|
|
7
|
-
#
|
|
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
|
|
8
10
|
# SubAgent.pack are the front doors.
|
|
9
11
|
#
|
|
10
12
|
# Every policy violation answers the model in band (unknown type, missing
|
|
11
|
-
# instructions, over capacity
|
|
12
|
-
#
|
|
13
|
+
# instructions, over capacity); only host configuration mistakes raise,
|
|
14
|
+
# and they raise at construction.
|
|
13
15
|
class Spawner
|
|
14
16
|
def initialize(provider:, tools: [], types: {}, models: [], max_children: 4,
|
|
15
|
-
dispatcher: nil, needs_approval: false,
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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)
|
|
20
23
|
|
|
21
24
|
@provider = provider
|
|
22
|
-
@pool = tools
|
|
25
|
+
@pool = tools.freeze
|
|
23
26
|
# Symbol keys are natural Ruby; the wire speaks strings. One
|
|
24
27
|
# normalization here and lookup, schema, and menu all agree.
|
|
25
|
-
@types = types.transform_keys(&:to_s)
|
|
26
|
-
@models = models
|
|
28
|
+
@types = types.transform_keys(&:to_s).freeze
|
|
29
|
+
@models = models.map { |model| String.new(model).freeze }.freeze
|
|
27
30
|
@max_children = max_children
|
|
28
31
|
@dispatcher = dispatcher
|
|
32
|
+
@runtime_factory = runtime_factory
|
|
29
33
|
@needs_approval = needs_approval
|
|
30
34
|
@agent_options = agent_options
|
|
31
35
|
validate_types!
|
|
@@ -46,26 +50,62 @@ module Mistri
|
|
|
46
50
|
worker = resolve_worker(args)
|
|
47
51
|
return worker if worker.is_a?(String)
|
|
48
52
|
|
|
49
|
-
if args["mode"] == "background" && @dispatcher
|
|
50
|
-
if args["workspace"] == "parent"
|
|
51
|
-
return "A worker sharing your workspace must run inline: a blocked parent " \
|
|
52
|
-
"cannot write concurrently, a working one can. Drop workspace or " \
|
|
53
|
-
"drop background."
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
return dispatch(args, worker, context)
|
|
57
|
-
end
|
|
53
|
+
return dispatch(args, worker, context) if args["mode"] == "background" && @dispatcher
|
|
58
54
|
|
|
59
|
-
SubAgent.run_child(label: label_for(args), provider: worker
|
|
55
|
+
SubAgent.run_child(label: label_for(args), provider: provider_for(worker),
|
|
60
56
|
system: worker[:system], tools: worker[:tools],
|
|
61
|
-
task: args.fetch("task"),
|
|
57
|
+
task: args.fetch("task"), parent_context: context,
|
|
58
|
+
agent_options: @agent_options)
|
|
62
59
|
end
|
|
63
60
|
|
|
64
61
|
private
|
|
65
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
|
+
|
|
66
106
|
# Create the child session and its lifecycle entries, hand the
|
|
67
|
-
# dispatcher the serializable spec plus an in-process runner
|
|
68
|
-
#
|
|
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:
|
|
69
109
|
# what the child's status says after dispatch, not what the mode
|
|
70
110
|
# promised. The runner closes over the spawn-time emit, so in-process
|
|
71
111
|
# dispatchers keep streaming to whoever watched the spawn.
|
|
@@ -73,28 +113,37 @@ module Mistri
|
|
|
73
113
|
store = context.session ? context.session.store : Stores::Memory.new
|
|
74
114
|
child = Session.new(store: store)
|
|
75
115
|
label = label_for(args)
|
|
76
|
-
context.session&.append("subagent", "name" => label, "session_id" => child.id)
|
|
77
|
-
child.append(Child::DISPATCHED, {})
|
|
78
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)
|
|
79
119
|
emit = context.emit
|
|
80
|
-
|
|
120
|
+
runtime_factory = @runtime_factory
|
|
81
121
|
runner = lambda do
|
|
82
|
-
SubAgent.run_dispatched(spec,
|
|
83
|
-
|
|
84
|
-
|
|
122
|
+
SubAgent.run_dispatched(spec, runtime_factory: runtime_factory,
|
|
123
|
+
store: store, emit: emit,
|
|
124
|
+
retry_factory_errors: false)
|
|
85
125
|
end
|
|
86
126
|
@dispatcher.call(spec, runner)
|
|
87
127
|
receipt(label, child, store)
|
|
88
128
|
end
|
|
89
129
|
|
|
90
130
|
def spec_for(args, worker, child, label, context)
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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"
|
|
98
147
|
end
|
|
99
148
|
|
|
100
149
|
def receipt(label, child, store)
|
|
@@ -127,9 +176,9 @@ module Mistri
|
|
|
127
176
|
|
|
128
177
|
system = [definition.render, args["instructions"]]
|
|
129
178
|
.reject { |part| part.to_s.strip.empty? }.join("\n\n")
|
|
130
|
-
chosen = args["tools"].nil?
|
|
179
|
+
chosen = args["tools"].nil? ? definition.tool_names : args["tools"]
|
|
131
180
|
{ system: system, tools: pick(chosen),
|
|
132
|
-
|
|
181
|
+
**provider_choice(args["model"], definition.model) }
|
|
133
182
|
end
|
|
134
183
|
|
|
135
184
|
def general_worker(args)
|
|
@@ -139,29 +188,29 @@ module Mistri
|
|
|
139
188
|
"or pick a type."
|
|
140
189
|
end
|
|
141
190
|
|
|
142
|
-
{ system: system, tools: pick(args["tools"]),
|
|
143
|
-
provider: child_provider(args["model"]) }
|
|
191
|
+
{ system: system, tools: pick(args["tools"]), **provider_choice(args["model"]) }
|
|
144
192
|
end
|
|
145
193
|
|
|
146
|
-
#
|
|
147
|
-
#
|
|
148
|
-
#
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
return @provider if definition_model.to_s.empty?
|
|
153
|
-
|
|
154
|
-
Mistri.provider(definition_model)
|
|
155
|
-
end
|
|
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?
|
|
156
200
|
|
|
157
|
-
|
|
158
|
-
|
|
201
|
+
model = @provider.model if @provider.respond_to?(:model)
|
|
202
|
+
return { provider: @provider, model: model }
|
|
203
|
+
end
|
|
159
204
|
unless @models.include?(requested)
|
|
160
205
|
raise ArgumentError,
|
|
161
206
|
"model #{requested.inspect} is not allowed; available: #{@models.join(", ")}"
|
|
162
207
|
end
|
|
163
208
|
|
|
164
|
-
|
|
209
|
+
{ model: requested }
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def provider_for(worker)
|
|
213
|
+
worker[:provider] || Mistri.provider(worker.fetch(:model))
|
|
165
214
|
end
|
|
166
215
|
|
|
167
216
|
def over_capacity(session)
|
|
@@ -199,7 +248,10 @@ module Mistri
|
|
|
199
248
|
end
|
|
200
249
|
|
|
201
250
|
def pick(names)
|
|
202
|
-
return @pool if names.nil?
|
|
251
|
+
return @pool if names.nil?
|
|
252
|
+
if names.uniq.length != names.length
|
|
253
|
+
raise ArgumentError, "tool selections cannot contain duplicate names"
|
|
254
|
+
end
|
|
203
255
|
|
|
204
256
|
by_name = @pool.to_h { |tool| [tool.name, tool] }
|
|
205
257
|
names.map do |name|
|
|
@@ -229,17 +281,15 @@ module Mistri
|
|
|
229
281
|
string :instructions, "The child's system prompt", required: true
|
|
230
282
|
end
|
|
231
283
|
if pool_names.any?
|
|
232
|
-
array :tools, "
|
|
233
|
-
|
|
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
|
|
234
287
|
end
|
|
235
288
|
string :model, "Model for the child#{fallback}", enum: models if models.any?
|
|
236
289
|
if dispatcher
|
|
237
290
|
string :mode, "inline blocks until the report; background returns a receipt " \
|
|
238
291
|
"now and you keep working (default: inline)",
|
|
239
292
|
enum: %w[inline background]
|
|
240
|
-
string :workspace, "own gives the worker its own file space; parent shares " \
|
|
241
|
-
"yours and requires inline mode (default: own)",
|
|
242
|
-
enum: %w[own parent]
|
|
243
293
|
end
|
|
244
294
|
end
|
|
245
295
|
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,7 +15,7 @@ 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
|
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
|