mistri 0.5.0 → 0.6.1
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 +480 -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 +11 -4
- 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 +190 -5
- 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
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mistri
|
|
4
|
+
# Agent options a child may inherit without replacing lifecycle-owned
|
|
5
|
+
# provider, session, prompt, tools, task, signal, or event state.
|
|
6
|
+
module ChildAgentOptions
|
|
7
|
+
ALLOWED = %i[
|
|
8
|
+
budget max_concurrency transform_context compaction retries skills
|
|
9
|
+
before_tool after_tool context
|
|
10
|
+
].freeze
|
|
11
|
+
private_constant :ALLOWED
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def validate!(options)
|
|
16
|
+
unsupported = options.keys - ALLOWED
|
|
17
|
+
return if unsupported.empty?
|
|
18
|
+
|
|
19
|
+
raise ConfigurationError,
|
|
20
|
+
"unsupported sub-agent options: #{unsupported.sort.join(", ")}"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
private_constant :ChildAgentOptions
|
|
24
|
+
|
|
25
|
+
class SubAgent
|
|
26
|
+
DISPATCH_SPEC_VERSION = 1
|
|
27
|
+
DISPATCH_SPEC_KEYS = %w[
|
|
28
|
+
spec_version name session_id parent_session_id type instructions task tool_names model
|
|
29
|
+
].freeze
|
|
30
|
+
UNSET_RUNTIME_FIELD = Object.new.freeze
|
|
31
|
+
private_constant :DISPATCH_SPEC_KEYS, :UNSET_RUNTIME_FIELD
|
|
32
|
+
|
|
33
|
+
# The live dependencies a host constructs for one dispatched child.
|
|
34
|
+
# Mistri verifies the provider and tools against the durable spec; the
|
|
35
|
+
# host owns tenant scope, backend isolation, and the freshness of every
|
|
36
|
+
# object placed here.
|
|
37
|
+
class Runtime
|
|
38
|
+
attr_reader :provider, :system, :tools, :schema, :agent_options
|
|
39
|
+
|
|
40
|
+
def initialize(provider:, system: nil, tools: [], schema: nil, cleanup: nil,
|
|
41
|
+
**agent_options)
|
|
42
|
+
raise ArgumentError, "runtime tools must be an Array" unless tools.is_a?(Array)
|
|
43
|
+
unless cleanup.nil? || cleanup.respond_to?(:call)
|
|
44
|
+
raise ArgumentError, "runtime cleanup must be callable"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
@provider = provider
|
|
48
|
+
@system = system
|
|
49
|
+
@tools = Array.new(tools).freeze
|
|
50
|
+
@schema = schema
|
|
51
|
+
@cleanup = cleanup
|
|
52
|
+
@agent_options = agent_options.dup.freeze
|
|
53
|
+
freeze
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def close = @cleanup&.call
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The fail-closed boundary between a durable dispatch spec and live Ruby
|
|
60
|
+
# dependencies. Kept separate from SubAgent's execution lifecycle so each
|
|
61
|
+
# concern stays auditable.
|
|
62
|
+
class RuntimeContract
|
|
63
|
+
Resolved = Data.define(:provider, :system, :tools, :schema, :agent_options)
|
|
64
|
+
private_constant :Resolved
|
|
65
|
+
|
|
66
|
+
class << self
|
|
67
|
+
def own_spec(spec)
|
|
68
|
+
owned, error = ToolArguments.canonicalize(spec)
|
|
69
|
+
unless error.nil? && owned.is_a?(Hash)
|
|
70
|
+
raise ConfigurationError, "dispatched child spec must be a bounded JSON object"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
owned
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def validate_identity!(spec)
|
|
77
|
+
%w[name session_id].each do |field|
|
|
78
|
+
value = spec[field]
|
|
79
|
+
unless value.is_a?(String) && !value.empty?
|
|
80
|
+
raise ConfigurationError, "dispatched child spec needs a non-empty #{field}"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def bind_spec(entries, supplied)
|
|
86
|
+
dispatched = entries.find { |entry| entry["type"] == Child::DISPATCHED }
|
|
87
|
+
raise DispatchGrantError, "child session has no durable dispatch grant" unless dispatched
|
|
88
|
+
|
|
89
|
+
stored = dispatched["spec"]
|
|
90
|
+
return supplied if stored.nil? && supplied["spec_version"].nil?
|
|
91
|
+
unless stored
|
|
92
|
+
raise DispatchGrantError, "versioned child is missing its durable dispatch grant"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
authoritative = own_spec(stored)
|
|
96
|
+
unless supplied == authoritative
|
|
97
|
+
raise DispatchGrantError, "queue payload does not match the durable dispatch grant"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
authoritative
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def validate_resolution!(factory:, direct:)
|
|
104
|
+
supplied = direct.values.any? { |value| !value.equal?(UNSET_RUNTIME_FIELD) }
|
|
105
|
+
if factory
|
|
106
|
+
raise ArgumentError, "choose runtime_factory or direct runtime fields" if supplied
|
|
107
|
+
unless factory.respond_to?(:call)
|
|
108
|
+
raise ConfigurationError, "runtime_factory must be callable"
|
|
109
|
+
end
|
|
110
|
+
elsif direct.fetch(:provider).equal?(UNSET_RUNTIME_FIELD)
|
|
111
|
+
raise ConfigurationError, "run_dispatched needs runtime_factory or provider"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def validate_spec!(spec)
|
|
116
|
+
version = spec["spec_version"]
|
|
117
|
+
validate_version!(version)
|
|
118
|
+
validate_v1_shape!(spec) if version == DISPATCH_SPEC_VERSION
|
|
119
|
+
validate_task!(spec["task"])
|
|
120
|
+
validate_model!(spec["model"], version: version)
|
|
121
|
+
validate_tool_names!(spec["tool_names"])
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def resolve(spec, factory:, direct:, &factory_failed)
|
|
125
|
+
return resolve_factory(spec, factory, &factory_failed) if factory
|
|
126
|
+
|
|
127
|
+
resolve_direct(direct)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def validate_runtime!(runtime, spec)
|
|
131
|
+
provider = runtime.provider
|
|
132
|
+
system = runtime.system
|
|
133
|
+
tools = runtime.tools
|
|
134
|
+
schema = runtime.schema
|
|
135
|
+
options = runtime.agent_options
|
|
136
|
+
validate_provider!(provider, spec["model"])
|
|
137
|
+
validate_agent_options!(options)
|
|
138
|
+
ordered = validate_tools!(tools, spec.fetch("tool_names"))
|
|
139
|
+
Resolved.new(provider: provider, system: system, tools: ordered,
|
|
140
|
+
schema: schema, agent_options: options)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def cleanup(runtime)
|
|
144
|
+
runtime&.close
|
|
145
|
+
nil
|
|
146
|
+
rescue StandardError => e
|
|
147
|
+
e
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
def validate_version!(version)
|
|
153
|
+
return if version.nil? || version == DISPATCH_SPEC_VERSION
|
|
154
|
+
|
|
155
|
+
raise ConfigurationError,
|
|
156
|
+
"unsupported dispatched child spec version #{version.inspect}"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def validate_task!(task)
|
|
160
|
+
return if task.is_a?(String) && !task.empty?
|
|
161
|
+
|
|
162
|
+
raise ConfigurationError, "dispatched child spec needs a non-empty task"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def validate_v1_shape!(spec)
|
|
166
|
+
extra = spec.keys - DISPATCH_SPEC_KEYS
|
|
167
|
+
unless extra.empty?
|
|
168
|
+
raise ConfigurationError,
|
|
169
|
+
"dispatched child spec has unknown fields: #{extra.join(", ")}"
|
|
170
|
+
end
|
|
171
|
+
validate_optional_string!(spec["parent_session_id"], "parent_session_id")
|
|
172
|
+
validate_required_string!(spec["type"], "type")
|
|
173
|
+
validate_optional_string!(spec["instructions"], "instructions")
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def validate_model!(model, version:)
|
|
177
|
+
return if version.nil? && model.nil?
|
|
178
|
+
return if model.is_a?(String) && !model.empty?
|
|
179
|
+
|
|
180
|
+
raise ConfigurationError, "dispatched child spec model must be a non-empty string"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def validate_required_string!(value, field)
|
|
184
|
+
return if value.is_a?(String) && !value.empty?
|
|
185
|
+
|
|
186
|
+
raise ConfigurationError,
|
|
187
|
+
"dispatched child spec #{field} must be a non-empty string"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def validate_optional_string!(value, field)
|
|
191
|
+
return if value.nil? || value.is_a?(String)
|
|
192
|
+
|
|
193
|
+
raise ConfigurationError,
|
|
194
|
+
"dispatched child spec #{field} must be a string or null"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def validate_tool_names!(names)
|
|
198
|
+
valid = names.is_a?(Array) &&
|
|
199
|
+
names.all? { |name| name.is_a?(String) && !name.empty? }
|
|
200
|
+
unless valid
|
|
201
|
+
raise ConfigurationError,
|
|
202
|
+
"dispatched child spec tool_names must be non-empty strings"
|
|
203
|
+
end
|
|
204
|
+
return if names.uniq.length == names.length
|
|
205
|
+
|
|
206
|
+
raise ConfigurationError, "dispatched child spec has duplicate tool names"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def resolve_factory(spec, factory)
|
|
210
|
+
runtime = begin
|
|
211
|
+
factory.call(spec)
|
|
212
|
+
rescue DispatchGrantError
|
|
213
|
+
yield if block_given?
|
|
214
|
+
raise ConfigurationError,
|
|
215
|
+
"runtime_factory must not raise Mistri::DispatchGrantError; " \
|
|
216
|
+
"that class is reserved for queue grant verification"
|
|
217
|
+
rescue StandardError
|
|
218
|
+
yield if block_given?
|
|
219
|
+
raise
|
|
220
|
+
end
|
|
221
|
+
return runtime if runtime.instance_of?(Runtime)
|
|
222
|
+
|
|
223
|
+
raise ConfigurationError,
|
|
224
|
+
"runtime_factory must return Mistri::SubAgent::Runtime"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def resolve_direct(direct)
|
|
228
|
+
provider = direct.fetch(:provider)
|
|
229
|
+
Runtime.new(provider: provider,
|
|
230
|
+
system: value_or_nil(direct.fetch(:system)),
|
|
231
|
+
tools: value_or(direct.fetch(:tools), []),
|
|
232
|
+
schema: value_or_nil(direct.fetch(:schema)),
|
|
233
|
+
**value_or(direct.fetch(:agent_options), {}))
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def value_or(value, fallback)
|
|
237
|
+
value.equal?(UNSET_RUNTIME_FIELD) ? fallback : value
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def value_or_nil(value) = value_or(value, nil)
|
|
241
|
+
|
|
242
|
+
def validate_provider!(provider, expected_model)
|
|
243
|
+
unless provider.respond_to?(:stream)
|
|
244
|
+
raise ConfigurationError, "background runtime provider must respond to stream"
|
|
245
|
+
end
|
|
246
|
+
return unless expected_model
|
|
247
|
+
|
|
248
|
+
actual = provider.model.to_s if provider.respond_to?(:model)
|
|
249
|
+
return if actual == expected_model
|
|
250
|
+
|
|
251
|
+
raise ConfigurationError,
|
|
252
|
+
"background runtime model #{actual.inspect} does not match " \
|
|
253
|
+
"the granted model #{expected_model.inspect}"
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def validate_agent_options!(options)
|
|
257
|
+
ChildAgentOptions.validate!(options)
|
|
258
|
+
return unless options.key?(:skills)
|
|
259
|
+
|
|
260
|
+
raise ConfigurationError,
|
|
261
|
+
"background runtime skills would add tools outside the durable grant"
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def validate_tools!(tools, expected)
|
|
265
|
+
unless tools.all?(Tool)
|
|
266
|
+
raise ConfigurationError, "background runtime tools must be Mistri::Tool instances"
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
names = tools.map(&:name)
|
|
270
|
+
if names.uniq.length != names.length
|
|
271
|
+
raise ConfigurationError, "background runtime has duplicate tool names"
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
check_exact_grant!(expected, names)
|
|
275
|
+
by_name = tools.to_h { |tool| [tool.name, tool] }
|
|
276
|
+
ordered = expected.map { |name| by_name.fetch(name) }
|
|
277
|
+
SubAgent.forbid_gated!(ordered)
|
|
278
|
+
ordered
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def check_exact_grant!(expected, names)
|
|
282
|
+
missing = expected - names
|
|
283
|
+
extra = names - expected
|
|
284
|
+
return if missing.empty? && extra.empty?
|
|
285
|
+
|
|
286
|
+
details = []
|
|
287
|
+
details << "missing: #{missing.join(", ")}" if missing.any?
|
|
288
|
+
details << "extra: #{extra.join(", ")}" if extra.any?
|
|
289
|
+
raise ConfigurationError,
|
|
290
|
+
"background runtime tools do not match the durable grant " \
|
|
291
|
+
"(#{details.join("; ")})"
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
private_constant :RuntimeContract
|
|
296
|
+
end
|
|
297
|
+
end
|
data/lib/mistri/sub_agent.rb
CHANGED
|
@@ -23,9 +23,10 @@ module Mistri
|
|
|
23
23
|
#
|
|
24
24
|
# spawn = Mistri::SubAgent.spawner(provider:, tools: [fetch_page, search])
|
|
25
25
|
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
# one turn
|
|
26
|
+
# The open spawner never grants itself, preventing accidental recursion.
|
|
27
|
+
# Hosts may deliberately nest fixed specialists. Several spawn calls in
|
|
28
|
+
# one turn are scheduled concurrently; provider instances decide whether
|
|
29
|
+
# their network requests overlap.
|
|
29
30
|
#
|
|
30
31
|
# Child events forward into the parent's stream tagged with origin
|
|
31
32
|
# ("researcher#ab12cd34"; nesting of named specialists joins with ">").
|
|
@@ -35,6 +36,8 @@ module Mistri
|
|
|
35
36
|
# runtime is denied and reported in band. Gate the delegation itself
|
|
36
37
|
# instead (needs_approval: on the definition or spawner).
|
|
37
38
|
class SubAgent
|
|
39
|
+
require_relative "sub_agent/runtime"
|
|
40
|
+
|
|
38
41
|
SPAWNER_DESCRIPTION =
|
|
39
42
|
"Delegate a self-contained task to a focused child agent with a clean " \
|
|
40
43
|
"context. The child starts blank: give it complete instructions and " \
|
|
@@ -50,6 +53,8 @@ module Mistri
|
|
|
50
53
|
def initialize(name:, description:, provider:, system: nil, tools: [], schema: nil,
|
|
51
54
|
**agent_options)
|
|
52
55
|
SubAgent.forbid_gated!(tools)
|
|
56
|
+
@gate = agent_options.delete(:needs_approval) || false
|
|
57
|
+
ChildAgentOptions.validate!(agent_options)
|
|
53
58
|
@name = name.to_s
|
|
54
59
|
@description = description
|
|
55
60
|
@provider = provider
|
|
@@ -57,7 +62,6 @@ module Mistri
|
|
|
57
62
|
@tools = tools
|
|
58
63
|
@schema = schema
|
|
59
64
|
@agent_options = agent_options
|
|
60
|
-
@gate = agent_options.delete(:needs_approval) || false
|
|
61
65
|
end
|
|
62
66
|
|
|
63
67
|
# The delegate tool: each call runs a fresh child and answers with its
|
|
@@ -83,8 +87,8 @@ module Mistri
|
|
|
83
87
|
def run_child(task, context, name: nil)
|
|
84
88
|
SubAgent.run_child(label: SubAgent.sanitize_label(name, fallback: @name),
|
|
85
89
|
provider: @provider, system: @system,
|
|
86
|
-
tools: @tools, task: task,
|
|
87
|
-
|
|
90
|
+
tools: @tools, task: task, parent_context: context, schema: @schema,
|
|
91
|
+
agent_options: @agent_options)
|
|
88
92
|
end
|
|
89
93
|
|
|
90
94
|
class << self
|
|
@@ -110,79 +114,141 @@ module Mistri
|
|
|
110
114
|
label.empty? ? fallback : label
|
|
111
115
|
end
|
|
112
116
|
|
|
113
|
-
def run_child(label:, provider:, system:, tools:, task:,
|
|
114
|
-
|
|
115
|
-
|
|
117
|
+
def run_child(label:, provider:, system:, tools:, task:, parent_context:, schema: nil,
|
|
118
|
+
agent_options: {})
|
|
119
|
+
ChildAgentOptions.validate!(agent_options)
|
|
120
|
+
store = parent_context.session ? parent_context.session.store : Stores::Memory.new
|
|
116
121
|
child = Session.new(store: store)
|
|
117
|
-
|
|
122
|
+
parent_context.session&.append("subagent", "name" => label, "session_id" => child.id)
|
|
118
123
|
# An inline child runs on a signal derived from the parent's: the
|
|
119
124
|
# parent's abort cascades down through the handle, while stopping
|
|
120
125
|
# the child alone leaves the parent running.
|
|
121
|
-
signal, cascade =
|
|
126
|
+
signal, cascade = if parent_context.signal
|
|
127
|
+
parent_context.signal.derive
|
|
128
|
+
else
|
|
129
|
+
[AbortSignal.new, nil]
|
|
130
|
+
end
|
|
122
131
|
result = begin
|
|
123
132
|
execute_child(child: child, label: label, provider: provider, system: system,
|
|
124
133
|
tools: tools, task: task, schema: schema, signal: signal,
|
|
125
|
-
emit:
|
|
134
|
+
emit: parent_context.emit, agent_options: agent_options)
|
|
135
|
+
rescue StandardError => e
|
|
136
|
+
unless child.entries.any? { |entry| entry["type"] == Child::TERMINAL }
|
|
137
|
+
child.append(Child::TERMINAL,
|
|
138
|
+
"status" => "failed", "error" => "#{e.class}: #{e.message}")
|
|
139
|
+
end
|
|
140
|
+
raise
|
|
126
141
|
ensure
|
|
127
|
-
|
|
142
|
+
parent_context.signal&.remove_callback(cascade) if cascade
|
|
128
143
|
end
|
|
129
144
|
outcome = answer(result, label, child)
|
|
130
145
|
child.append(Child::TERMINAL, terminal(result))
|
|
131
146
|
outcome
|
|
132
147
|
end
|
|
133
148
|
|
|
134
|
-
# The host job's way back in:
|
|
135
|
-
#
|
|
136
|
-
#
|
|
137
|
-
#
|
|
138
|
-
#
|
|
139
|
-
#
|
|
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.
|
|
149
|
+
# The host job's way back in: a runtime factory turns the durable spec
|
|
150
|
+
# into live dependencies inside the worker. The compatible direct
|
|
151
|
+
# provider/system/tools form remains for hosts that already reconstruct
|
|
152
|
+
# those values in their job. Either form is checked against the spec
|
|
153
|
+
# before execution. The child then runs exactly like an inline child,
|
|
154
|
+
# streams origin-tagged events, and reports back to its parent.
|
|
142
155
|
#
|
|
143
|
-
# The child
|
|
144
|
-
#
|
|
145
|
-
#
|
|
146
|
-
#
|
|
147
|
-
#
|
|
148
|
-
#
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
**agent_options)
|
|
156
|
+
# The child lease is duplicate suppression, not an exactly-once claim:
|
|
157
|
+
# a refused delivery leaves the current holder alone. A terminal means
|
|
158
|
+
# a queued cancellation or finished retry and returns nil. The runner
|
|
159
|
+
# retains an acquired lease through terminal persistence and reporting,
|
|
160
|
+
# suppressing ordinary redelivery while that lease remains live.
|
|
161
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- ordered dispatch transitions are the safety contract
|
|
162
|
+
def run_dispatched(spec, store:, emit: nil, runtime_factory: nil,
|
|
163
|
+
provider: UNSET_RUNTIME_FIELD, system: UNSET_RUNTIME_FIELD,
|
|
164
|
+
tools: UNSET_RUNTIME_FIELD, schema: UNSET_RUNTIME_FIELD,
|
|
165
|
+
retry_factory_errors: true, **agent_options)
|
|
166
|
+
runtime = nil
|
|
167
|
+
resolved = nil
|
|
168
|
+
authorized = false
|
|
169
|
+
terminalize = false
|
|
170
|
+
factory_failed = false
|
|
171
|
+
retryable_exit = false
|
|
172
|
+
primary_error = nil
|
|
173
|
+
delivery_owned = false
|
|
174
|
+
spec = RuntimeContract.own_spec(spec)
|
|
175
|
+
RuntimeContract.validate_identity!(spec)
|
|
153
176
|
child = Session.new(store: store, id: spec.fetch("session_id"))
|
|
177
|
+
spec = RuntimeContract.bind_spec(child.entries, spec)
|
|
178
|
+
authorized = true
|
|
179
|
+
direct = { provider: provider, system: system, tools: tools, schema: schema,
|
|
180
|
+
agent_options: agent_options.empty? ? UNSET_RUNTIME_FIELD : agent_options }
|
|
181
|
+
RuntimeContract.validate_resolution!(factory: runtime_factory, direct: direct)
|
|
154
182
|
signal = AbortSignal.new
|
|
183
|
+
terminalize = true
|
|
184
|
+
delivery_owned = true
|
|
155
185
|
lease = Locks.hold(Child.lease_key(child.id),
|
|
156
186
|
stop_key: Child.stop_key(child.id), signal: signal)
|
|
157
|
-
|
|
187
|
+
if Mistri.locks && lease.nil?
|
|
188
|
+
delivery_owned = false
|
|
189
|
+
return nil
|
|
190
|
+
end
|
|
158
191
|
|
|
159
192
|
if Child.new(name: spec.fetch("name"), session_id: child.id, store: store).finished?
|
|
160
|
-
lease&.release
|
|
161
193
|
return nil
|
|
162
194
|
end
|
|
163
195
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
196
|
+
child.append(Child::STARTED, {})
|
|
197
|
+
RuntimeContract.validate_spec!(spec)
|
|
198
|
+
signal.abort!("stopped by user") if Mistri.locks&.flag?(Child.stop_key(child.id))
|
|
199
|
+
unless signal.aborted?
|
|
200
|
+
runtime = RuntimeContract.resolve(spec, factory: runtime_factory, direct: direct) do
|
|
201
|
+
factory_failed = true
|
|
202
|
+
end
|
|
203
|
+
resolved = RuntimeContract.validate_runtime!(runtime, spec)
|
|
204
|
+
signal.abort!("stopped by user") if Mistri.locks&.flag?(Child.stop_key(child.id))
|
|
205
|
+
end
|
|
206
|
+
result = if signal.aborted?
|
|
207
|
+
Result.new(message: nil, status: :aborted)
|
|
208
|
+
else
|
|
209
|
+
execute_child(child: child, label: spec.fetch("name"),
|
|
210
|
+
provider: resolved.provider, system: resolved.system,
|
|
211
|
+
tools: resolved.tools, task: spec.fetch("task"),
|
|
212
|
+
schema: resolved.schema, signal: signal, emit: emit,
|
|
213
|
+
lease: lease, started: true,
|
|
214
|
+
agent_options: resolved.agent_options)
|
|
215
|
+
end
|
|
168
216
|
deny_pending(result, child)
|
|
169
217
|
child.append(Child::TERMINAL, terminal(result))
|
|
170
218
|
result
|
|
171
219
|
rescue StandardError => e
|
|
220
|
+
primary_error = e
|
|
172
221
|
# Completion is a contract even when the runner dies in the
|
|
173
222
|
# preamble: without this, a raise before the started entry (a lock
|
|
174
223
|
# backend down, say) would leave the child reading :queued forever,
|
|
175
224
|
# with nothing to report and nothing for a retry to heal.
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
225
|
+
stopped = factory_failed && child_stop_requested?(child, signal)
|
|
226
|
+
retryable = factory_failed && retry_factory_errors && !stopped
|
|
227
|
+
retryable_exit = retryable
|
|
228
|
+
if child && terminalize && !retryable &&
|
|
229
|
+
!Child.new(name: spec.fetch("name"), session_id: child.id, store: store).finished?
|
|
230
|
+
terminal = if stopped
|
|
231
|
+
{ "status" => "stopped" }
|
|
232
|
+
else
|
|
233
|
+
{ "status" => "failed", "error" => "#{e.class}: #{e.message}" }
|
|
234
|
+
end
|
|
235
|
+
child.append(Child::TERMINAL, terminal)
|
|
180
236
|
end
|
|
181
|
-
lease&.release
|
|
182
237
|
raise
|
|
183
238
|
ensure
|
|
184
|
-
|
|
239
|
+
cleanup_error = RuntimeContract.cleanup(runtime)
|
|
240
|
+
begin
|
|
241
|
+
report_back(spec, store, emit) if child && authorized && delivery_owned
|
|
242
|
+
ensure
|
|
243
|
+
begin
|
|
244
|
+
Mistri.locks&.clear_flag(Child.stop_key(child.id)) if child && lease && !retryable_exit
|
|
245
|
+
ensure
|
|
246
|
+
lease&.release
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
raise cleanup_error if cleanup_error && primary_error.nil?
|
|
185
250
|
end
|
|
251
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
186
252
|
|
|
187
253
|
# A child cannot wait for a human, whichever door it entered by: any
|
|
188
254
|
# calls parked for approval are denied AND settled with the denial as
|
|
@@ -195,7 +261,7 @@ module Mistri
|
|
|
195
261
|
child.deny(call.id, note: "sub-agents cannot pause for human approval")
|
|
196
262
|
child.append_message(Message.tool(
|
|
197
263
|
content: "Denied: sub-agents cannot pause for human approval.",
|
|
198
|
-
tool_call_id: call.id, tool_name: call.name
|
|
264
|
+
tool_call_id: call.id, tool_name: call.name, tool_error: true
|
|
199
265
|
))
|
|
200
266
|
end
|
|
201
267
|
end
|
|
@@ -225,51 +291,18 @@ module Mistri
|
|
|
225
291
|
|
|
226
292
|
private
|
|
227
293
|
|
|
228
|
-
|
|
229
|
-
|
|
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
|
|
294
|
+
def child_stop_requested?(child, signal)
|
|
295
|
+
return false unless child
|
|
262
296
|
|
|
263
|
-
|
|
264
|
-
emit.call(event.with(origin: tagged))
|
|
297
|
+
signal&.aborted? || Mistri.locks&.flag?(Child.stop_key(child.id))
|
|
265
298
|
end
|
|
266
299
|
|
|
267
|
-
#
|
|
268
|
-
# the parent's inbox (a typed entry that folds at its next
|
|
269
|
-
# boundary, exactly like a steer) and a :subagent_report event
|
|
300
|
+
# A lease-backed runner reports before releasing the child lease. The
|
|
301
|
+
# report joins the parent's inbox (a typed entry that folds at its next
|
|
302
|
+
# turn boundary, exactly like a steer) and a :subagent_report event
|
|
270
303
|
# closes the child's lane in whatever UI watched the spawn. A child
|
|
271
304
|
# that never ran has nothing to say, and the parent session drops a
|
|
272
|
-
# duplicate delivery
|
|
305
|
+
# sequential duplicate delivery. Without locks, the host must serialize.
|
|
273
306
|
def report_back(spec, store, emit)
|
|
274
307
|
facade = Child.new(name: spec.fetch("name"), session_id: spec.fetch("session_id"),
|
|
275
308
|
store: store)
|
|
@@ -301,12 +334,14 @@ module Mistri
|
|
|
301
334
|
when :awaiting_approval
|
|
302
335
|
deny_pending(result, child)
|
|
303
336
|
ToolResult.new(content: "The #{label} sub-agent stopped: it needed human " \
|
|
304
|
-
"approval, which sub-agents cannot wait for.", ui: link
|
|
337
|
+
"approval, which sub-agents cannot wait for.", ui: link,
|
|
338
|
+
error: true)
|
|
305
339
|
when :aborted
|
|
306
|
-
ToolResult.new(content: "[the #{label} sub-agent was stopped]", ui: link)
|
|
340
|
+
ToolResult.new(content: "[the #{label} sub-agent was stopped]", ui: link, error: true)
|
|
307
341
|
else
|
|
308
342
|
reason = result.error_message || result.status
|
|
309
|
-
ToolResult.new(content: "The #{label} sub-agent failed: #{reason}", ui: link
|
|
343
|
+
ToolResult.new(content: "The #{label} sub-agent failed: #{reason}", ui: link,
|
|
344
|
+
error: true)
|
|
310
345
|
end
|
|
311
346
|
end
|
|
312
347
|
|
|
@@ -320,3 +355,5 @@ module Mistri
|
|
|
320
355
|
end
|
|
321
356
|
end
|
|
322
357
|
end
|
|
358
|
+
|
|
359
|
+
require_relative "sub_agent/execution"
|
data/lib/mistri/task_output.rb
CHANGED
|
@@ -9,26 +9,37 @@ module Mistri
|
|
|
9
9
|
module TaskOutput
|
|
10
10
|
# Distinguishable from a parsed nil: JSON "null" is a valid value.
|
|
11
11
|
PARSE_FAILED = Object.new.freeze
|
|
12
|
+
OUTPUT_TOO_LARGE = Object.new.freeze
|
|
13
|
+
OUTPUT_TOO_COMPLEX = Object.new.freeze
|
|
12
14
|
|
|
13
15
|
module_function
|
|
14
16
|
|
|
15
17
|
def prompt(input, schema)
|
|
18
|
+
plan = plan_for(schema)
|
|
16
19
|
"#{input}\n\nAnswer with ONLY a JSON value matching this schema:\n" \
|
|
17
|
-
"#{JSON.generate(
|
|
20
|
+
"#{JSON.generate(plan.schema)}"
|
|
18
21
|
end
|
|
19
22
|
|
|
20
23
|
def parse(text)
|
|
21
|
-
body = text.to_s
|
|
24
|
+
body = text.to_s
|
|
25
|
+
return OUTPUT_TOO_LARGE if body.bytesize > ToolArguments::MAX_BYTES
|
|
26
|
+
|
|
27
|
+
body = body.strip
|
|
22
28
|
body = body[/\A```(?:json)?\s*(.*?)```\z/m, 1] || body
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
29
|
+
value, error = ToolArguments.parse_json(body)
|
|
30
|
+
return OUTPUT_TOO_LARGE if error == "too_large"
|
|
31
|
+
return OUTPUT_TOO_COMPLEX if %w[too_deep too_many_nodes number_too_large].include?(error)
|
|
32
|
+
return PARSE_FAILED if error
|
|
33
|
+
|
|
34
|
+
value
|
|
26
35
|
end
|
|
27
36
|
|
|
28
37
|
def errors(value, schema)
|
|
38
|
+
return ["the answer exceeds the output byte limit"] if value.equal?(OUTPUT_TOO_LARGE)
|
|
39
|
+
return ["the answer exceeds the output complexity limit"] if value.equal?(OUTPUT_TOO_COMPLEX)
|
|
29
40
|
return ["the answer is not valid JSON"] if value.equal?(PARSE_FAILED)
|
|
30
41
|
|
|
31
|
-
|
|
42
|
+
plan_for(schema).violations(value)
|
|
32
43
|
end
|
|
33
44
|
|
|
34
45
|
def fix_prompt(errors)
|
|
@@ -36,5 +47,12 @@ module Mistri
|
|
|
36
47
|
"Your answer did not satisfy the required output schema. Problems:\n" \
|
|
37
48
|
"#{lines}\nReply with ONLY the corrected JSON."
|
|
38
49
|
end
|
|
50
|
+
|
|
51
|
+
def plan_for(schema)
|
|
52
|
+
return schema if schema.respond_to?(:schema) && schema.respond_to?(:violations)
|
|
53
|
+
|
|
54
|
+
Schema.task_plan(schema)
|
|
55
|
+
end
|
|
56
|
+
private_class_method :plan_for
|
|
39
57
|
end
|
|
40
58
|
end
|