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.
Files changed (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +469 -4
  3. data/CONTRIBUTING.md +52 -0
  4. data/README.md +289 -385
  5. data/SECURITY.md +40 -0
  6. data/UPGRADING.md +640 -0
  7. data/assets/logo-animated.svg +30 -0
  8. data/assets/logo-dark.svg +14 -0
  9. data/assets/logo-light.svg +14 -0
  10. data/assets/logo.svg +14 -0
  11. data/assets/social-preview.png +0 -0
  12. data/docs/README.md +87 -0
  13. data/docs/context-and-workspaces.md +378 -0
  14. data/docs/mcp.md +366 -0
  15. data/docs/reliability.md +450 -0
  16. data/docs/sessions.md +295 -0
  17. data/docs/sub-agents.md +401 -0
  18. data/docs/tool-contracts.md +324 -0
  19. data/examples/approval.rb +36 -0
  20. data/examples/browser.rb +27 -0
  21. data/examples/page_editor.rb +31 -0
  22. data/examples/quickstart.rb +21 -0
  23. data/lib/generators/mistri/install/install_generator.rb +7 -3
  24. data/lib/generators/mistri/install/templates/migration.rb.tt +2 -2
  25. data/lib/generators/mistri/mcp/templates/migration.rb.tt +1 -1
  26. data/lib/generators/mistri/mcp/templates/model.rb.tt +15 -8
  27. data/lib/mistri/agent.rb +575 -55
  28. data/lib/mistri/budget.rb +26 -1
  29. data/lib/mistri/child.rb +72 -16
  30. data/lib/mistri/compaction.rb +26 -10
  31. data/lib/mistri/compactor.rb +34 -11
  32. data/lib/mistri/console.rb +28 -7
  33. data/lib/mistri/content.rb +9 -3
  34. data/lib/mistri/dispatchers.rb +14 -12
  35. data/lib/mistri/errors.rb +83 -4
  36. data/lib/mistri/event.rb +24 -8
  37. data/lib/mistri/event_delivery.rb +60 -0
  38. data/lib/mistri/locks.rb +3 -3
  39. data/lib/mistri/mcp/client.rb +74 -19
  40. data/lib/mistri/mcp/egress.rb +216 -0
  41. data/lib/mistri/mcp/oauth.rb +476 -127
  42. data/lib/mistri/mcp/wires.rb +115 -23
  43. data/lib/mistri/mcp.rb +42 -8
  44. data/lib/mistri/message.rb +21 -11
  45. data/lib/mistri/models.rb +160 -22
  46. data/lib/mistri/providers/anthropic/assembler.rb +282 -44
  47. data/lib/mistri/providers/anthropic/serializer.rb +14 -9
  48. data/lib/mistri/providers/anthropic.rb +29 -6
  49. data/lib/mistri/providers/fake.rb +26 -10
  50. data/lib/mistri/providers/gemini/assembler.rb +148 -21
  51. data/lib/mistri/providers/gemini/serializer.rb +78 -9
  52. data/lib/mistri/providers/gemini.rb +31 -5
  53. data/lib/mistri/providers/openai/assembler.rb +337 -60
  54. data/lib/mistri/providers/openai/serializer.rb +13 -12
  55. data/lib/mistri/providers/openai.rb +29 -5
  56. data/lib/mistri/providers/schema_capabilities.rb +214 -0
  57. data/lib/mistri/result.rb +1 -1
  58. data/lib/mistri/schema.rb +893 -75
  59. data/lib/mistri/session.rb +560 -48
  60. data/lib/mistri/sinks/coalesced.rb +17 -10
  61. data/lib/mistri/spawner.rb +111 -61
  62. data/lib/mistri/sse.rb +57 -14
  63. data/lib/mistri/stores/active_record.rb +1 -1
  64. data/lib/mistri/stores/memory.rb +21 -2
  65. data/lib/mistri/sub_agent/execution.rb +81 -0
  66. data/lib/mistri/sub_agent/runtime.rb +297 -0
  67. data/lib/mistri/sub_agent.rb +124 -87
  68. data/lib/mistri/task_output.rb +24 -6
  69. data/lib/mistri/tool.rb +93 -13
  70. data/lib/mistri/tool_arguments.rb +377 -0
  71. data/lib/mistri/tool_call.rb +43 -9
  72. data/lib/mistri/tool_context.rb +4 -2
  73. data/lib/mistri/tool_executor.rb +117 -26
  74. data/lib/mistri/tool_result.rb +15 -10
  75. data/lib/mistri/tools/edit_file.rb +62 -8
  76. data/lib/mistri/tools.rb +41 -4
  77. data/lib/mistri/transport.rb +149 -44
  78. data/lib/mistri/usage.rb +65 -13
  79. data/lib/mistri/version.rb +1 -1
  80. data/lib/mistri/workspace/active_record.rb +183 -3
  81. data/lib/mistri/workspace/directory.rb +28 -8
  82. data/lib/mistri/workspace/memory.rb +34 -9
  83. data/lib/mistri/workspace/single.rb +62 -5
  84. data/lib/mistri/workspace.rb +39 -0
  85. data/lib/mistri.rb +6 -1
  86. data/mistri.gemspec +34 -0
  87. metadata +31 -3
data/docs/sessions.md ADDED
@@ -0,0 +1,295 @@
1
+ # Sessions and control
2
+
3
+ A session is Mistri's source of truth: an append-only sequence of typed JSON
4
+ entries. Messages, approvals, steering, compaction boundaries, child links, and
5
+ reports share that one record. Derived state is rebuilt from the log instead of
6
+ being repaired in place after a crash. Persistence strength belongs to the
7
+ chosen store.
8
+
9
+ ## Sessions
10
+
11
+ Every Agent has a Session. Without one, Mistri creates an in-memory session:
12
+
13
+ ```ruby
14
+ agent = Mistri.agent("claude-opus-4-8")
15
+ agent.session.id
16
+ ```
17
+
18
+ Pass a persistent store when work must survive a process:
19
+
20
+ ```ruby
21
+ store = Mistri::Stores::JSONL.new("tmp/mistri-sessions")
22
+ session = Mistri::Session.new(store: store)
23
+
24
+ agent = Mistri.agent("claude-opus-4-8", session: session)
25
+ agent.run("Start the research plan.")
26
+
27
+ reloaded = Mistri::Session.new(store: store, id: session.id)
28
+ Mistri.agent("claude-opus-4-8", session: reloaded).run("Continue the plan.")
29
+ ```
30
+
31
+ `Session#entries` returns the raw typed log. `Session#messages` returns the
32
+ provider-neutral conversation after replay and compaction. `Session#transcript`
33
+ returns a presentation-oriented entry view with inline image bytes removed. It
34
+ is not an authorization or redaction boundary: prompts, tool arguments and
35
+ results, UI payloads, errors, and provider metadata can remain. Authorize and
36
+ redact before exposing a transcript.
37
+
38
+ Mistri does not encrypt JSONL or Active Record session payloads. The host owns
39
+ storage encryption, access control, retention, deletion, and redaction specific
40
+ to the application. Treat the complete session record as sensitive data.
41
+
42
+ ## Store contract
43
+
44
+ A store implements:
45
+
46
+ ```ruby
47
+ append(session_id, entry_hash) # append one JSON-shaped entry
48
+ load(session_id) # return entries in append order
49
+ ```
50
+
51
+ The store must preserve each append as one entry, provide a stable total order
52
+ per session, and make a successful append visible to subsequent loads. Values
53
+ returned by `load` must not let caller mutation rewrite durable history: return
54
+ fresh decoded copies or deeply immutable snapshots. Mistri does not rely on
55
+ Ruby object identity. `Session#append` normalizes entries through JSON, so store
56
+ implementations see string keys and JSON values.
57
+
58
+ Built-in stores are:
59
+
60
+ - `Mistri::Stores::Memory` for one process and tests;
61
+ - `Mistri::Stores::JSONL` for one file per session;
62
+ - `Mistri::Stores::ActiveRecord` for a host model and database.
63
+
64
+ JSONL closes each append, so completed lines survive an ordinary process
65
+ restart. It does not call `fsync`, provide a database transaction, or promise
66
+ power-loss durability. Use a database or host store when that distinction
67
+ matters.
68
+
69
+ ### Active Record
70
+
71
+ The adapter is opt-in and Mistri never requires Rails at boot:
72
+
73
+ ```ruby
74
+ require "mistri/stores/active_record"
75
+
76
+ store = Mistri::Stores::ActiveRecord.new(AgentEntry)
77
+ ```
78
+
79
+ Generate a host-named model and migration in Rails:
80
+
81
+ ```console
82
+ $ bin/rails generate mistri:install AgentEntry
83
+ ```
84
+
85
+ The table needs `session_id`, `position`, and `payload`, plus a unique index on
86
+ `[session_id, position]`. The unique index is the optimistic append boundary for
87
+ independent writers.
88
+
89
+ On MySQL and Trilogy, `payload` must be LONGTEXT because one legal parallel
90
+ tool turn can exceed MEDIUMTEXT even while each call stays inside its own
91
+ limit. New migrations use `size: :long`. Existing tables need a host migration:
92
+
93
+ ```ruby
94
+ change_column :agent_entries, :payload, :text, size: :long, null: false
95
+ ```
96
+
97
+ PostgreSQL `text` does not need that change.
98
+
99
+ ## Run and resume
100
+
101
+ `run` appends a new user input and advances the model loop. Do not call it while
102
+ the session has open approvals; use `resume`.
103
+
104
+ `resume` does not append user text. It audits open approval state, returns
105
+ immediately if a decision is still missing, settles decided calls, and then
106
+ continues the existing exchange.
107
+
108
+ Rebuild the Agent with the current tool definitions, authorization context,
109
+ hooks, provider configuration, and business policy before resuming. The Session
110
+ contains durable facts, not frozen application policy.
111
+
112
+ ## Human approval
113
+
114
+ Declare a boolean or argument predicate on the tool:
115
+
116
+ ```ruby
117
+ send_gift = Mistri::Tool.define(
118
+ "send_gift", "Sends a physical gift.",
119
+ schema: lambda {
120
+ string :recipient_id, "Recipient ID", required: true
121
+ number :value_usd, "Gift value", required: true
122
+ },
123
+ needs_approval: ->(args) { args.fetch("value_usd") > 100 },
124
+ ) do |args|
125
+ Gifts.send!(
126
+ recipient_id: args.fetch("recipient_id"),
127
+ value_usd: args.fetch("value_usd"),
128
+ )
129
+ end
130
+ ```
131
+
132
+ When the model calls it:
133
+
134
+ ```ruby
135
+ result = agent.run("Send the launch gift to customer 42")
136
+ result.awaiting_approval? # => true
137
+
138
+ call = result.pending.first
139
+ session_id = agent.session.id
140
+ ```
141
+
142
+ The handler has not run. Persist or route `session_id` and `call.id` in the
143
+ host's own UI. A decision needs only a store and Session:
144
+
145
+ ```ruby
146
+ session = Mistri::Session.new(store: store, id: session_id)
147
+ session.approve(call.id, note: "Approved by finance")
148
+ # or: session.deny(call.id, note: "Use the standard gift instead")
149
+ ```
150
+
151
+ The decision may be appended from another process at any later time. Nothing
152
+ sleeps while waiting. To continue:
153
+
154
+ ```ruby
155
+ session = Mistri::Session.new(store: store, id: session_id)
156
+ agent = Mistri.agent(
157
+ "claude-opus-4-8",
158
+ tools: current_tools,
159
+ session: session,
160
+ context: current_authorization_context,
161
+ before_tool: current_policy,
162
+ )
163
+
164
+ result = agent.resume
165
+ ```
166
+
167
+ Approved arguments are the exact prepared value the human reviewed. Resume
168
+ verifies their source and pairing metadata, revalidates them against the current
169
+ schema, and runs `before_tool` again. It does not renormalize or reevaluate the
170
+ approval predicate.
171
+
172
+ `Session#open_approvals` derives every still-open call and its decision from the
173
+ log. A decision is single-assignment: the first valid decision in durable store
174
+ order wins, repeating that winning value is idempotent, and a conflicting caller
175
+ receives `Mistri::ConfigurationError`. Concurrent losing attempts can remain in
176
+ the raw append-only log, but they are inert and cannot revoke the winner or
177
+ corrupt the Session. The first note wins. Duplicate requests and malformed,
178
+ orphaned, or mismatched controls still fail closed.
179
+
180
+ Mistri does not implement quorum or denial precedence. Resolve multi-reviewer
181
+ policy in the host before recording its one final Session decision. A host that
182
+ must prevent losing attempts from reaching the raw log also needs to serialize
183
+ its decision endpoint; the two-method store contract reconciles concurrent
184
+ appends rather than conditionally preventing them.
185
+
186
+ Denial is returned to the model as an expected result so it can choose another
187
+ path. It is not marked as a tool execution error.
188
+
189
+ ## Steering and the inbox
190
+
191
+ Steering queues a user message for the next turn boundary:
192
+
193
+ ```ruby
194
+ Mistri::Session.new(store: store, id: session_id).steer(
195
+ "Focus only on contracts renewed this quarter."
196
+ )
197
+ ```
198
+
199
+ The active Agent does not need to be shared with the writer. If a steer lands
200
+ while the model finishes cleanly, the run extends for another turn so the new
201
+ instruction is answered.
202
+
203
+ Background child reports use the same ordered inbox. A host that wakes idle
204
+ sessions should watch `session.pending_inbox`, not only
205
+ `session.pending_steers`, so worker reports receive the same treatment.
206
+
207
+ Inbox consumption is itself an append: the folded message records the source
208
+ entry ID. A crash cannot consume a steer invisibly or require a repair write.
209
+
210
+ ## Compaction
211
+
212
+ For a catalogued model, automatic compaction begins when estimated context use
213
+ crosses the published window minus safe output headroom. Disable it with
214
+ `compaction: false`, or pass policy explicitly:
215
+
216
+ ```ruby
217
+ settings = Mistri::Compaction.new(
218
+ reserve: 64_000,
219
+ keep_recent: 24_000,
220
+ instructions: "Preserve decisions, identifiers, and unresolved risks.",
221
+ )
222
+
223
+ agent = Mistri.agent("claude-opus-4-8", compaction: settings)
224
+ ```
225
+
226
+ An explicit `window:` enables compaction for an uncatalogued deployment. An
227
+ explicit `reserve:` is host policy; the automatic default otherwise protects a
228
+ catalogued model's shared output capacity plus framing slack. Gemini publishes
229
+ an independent input limit, so its automatic reserve remains input-oriented.
230
+
231
+ ```ruby
232
+ agent.context_usage
233
+ # => { tokens: 141_000, window: 1_000_000, fraction: 0.141 }
234
+
235
+ agent.compact
236
+ ```
237
+
238
+ Manual `compact` returns nil when there is no useful cut. During a compaction,
239
+ `:compacting` announces that work began. The later `:compaction` event carries
240
+ the visible summary in `event.content`.
241
+
242
+ The summary and kept-tail boundary append to the session. Provider replay then
243
+ uses the visible summary plus the retained tail, while the full original log
244
+ remains available through `entries` and `transcript`. Compaction cannot hide an
245
+ open approval or split a completed tool call from its result.
246
+
247
+ Very large tool results may be shortened only in the request sent to the
248
+ summarizer. The durable result and ordinary replay remain unchanged until a
249
+ successful compaction intentionally replaces earlier context with the summary.
250
+
251
+ ## Provider changes
252
+
253
+ The shared message model permits a session to continue with another built-in
254
+ provider. Opaque reasoning, signatures, and pairing metadata replay only to the
255
+ provider that created them.
256
+
257
+ Completed foreign tool exchanges become a neutral representation when needed.
258
+ In particular, Mistri does not manufacture a signed Gemini function call from
259
+ another provider's metadata.
260
+
261
+ Changing providers can alter model behavior even when replay is valid. Treat it
262
+ as explicit host policy, not transparent failover.
263
+
264
+ ## Transcripts and child sessions
265
+
266
+ ```ruby
267
+ session.transcript
268
+ session.transcript(include_children: true)
269
+ ```
270
+
271
+ With `include_children: true`, each child transcript is spliced after its link
272
+ entry. Entries carry the same origin labels used by live events, so a reloaded
273
+ UI can reconstruct parent and worker lanes. See [Sub-agents](sub-agents.md).
274
+
275
+ ## Concurrency and crashes
276
+
277
+ Independent appenders such as approvals, steering, and child reports are part
278
+ of the design. Active execution is different: only one Agent may call `run` or
279
+ `resume` for a session at a time.
280
+
281
+ Mistri persists each completed message promptly, but a process can still fail
282
+ after a handler starts or commits an external write and before its result is
283
+ durable. Replay heals an unanswered call with an interrupted error so the
284
+ provider history remains pairable. That error explicitly says the tool may
285
+ have run; it does not make replay safe.
286
+
287
+ Hosts must serialize active runners and make side effects idempotent or
288
+ reconcilable. Read [Reliability](reliability.md) for the complete contract.
289
+
290
+ ## Related guides
291
+
292
+ - [Tool contracts](tool-contracts.md)
293
+ - [Sub-agents](sub-agents.md)
294
+ - [Reliability](reliability.md)
295
+ - [Upgrading](../UPGRADING.md)
@@ -0,0 +1,401 @@
1
+ # Sub-agents
2
+
3
+ A sub-agent runs a self-contained task in a new Session. Its exploration stays
4
+ out of the parent's context; only the final report returns. The child transcript
5
+ remains linked for inspection, streaming, and UI reconstruction.
6
+
7
+ Mistri supports two shapes on that mechanism:
8
+
9
+ - a fixed specialist curated by the host;
10
+ - a host-bounded spawn tool whose model chooses a name, instructions, type,
11
+ tool subset, model, and execution mode within explicit allowlists.
12
+
13
+ The open Spawner rejects `spawn_agent` in its own pool, so model-composed
14
+ recursive spawning is not available by default. A host may deliberately give a
15
+ fixed specialist another specialist or spawn tool; nested origins and
16
+ transcripts are supported.
17
+
18
+ ## Fixed specialists
19
+
20
+ ```ruby
21
+ researcher = Mistri::SubAgent.new(
22
+ name: "researcher",
23
+ description: "Reads approved sources and answers factual questions.",
24
+ provider: Mistri.provider("claude-haiku-4-5"),
25
+ system: "Research the question. Return findings and source URLs.",
26
+ tools: [fetch_page],
27
+ )
28
+
29
+ agent = Mistri.agent(
30
+ "claude-opus-4-8",
31
+ tools: [researcher.tool],
32
+ )
33
+ ```
34
+
35
+ Each call creates a fresh child Session in the parent's store. The model may
36
+ provide a display name for that run, so parallel specialists can appear as
37
+ distinct lanes.
38
+
39
+ Pass `schema:` to validate a normally completed report as structured output.
40
+ An `ends_turn` tool hands off with unvalidated text and `output` nil, so do not
41
+ grant one when every report must satisfy the schema. Other Agent options, such
42
+ as a budget or compaction policy, pass to the child through
43
+ `Mistri::SubAgent.new`.
44
+
45
+ ## The spawn tool
46
+
47
+ The open spawner lets the parent compose a worker from a pool the host controls:
48
+
49
+ ```ruby
50
+ spawn = Mistri::SubAgent.spawner(
51
+ provider: child_provider,
52
+ tools: [fetch_page, search_catalog],
53
+ models: ["claude-haiku-4-5", "gpt-5-nano"],
54
+ max_children: 4,
55
+ )
56
+
57
+ agent = Mistri.agent("claude-opus-4-8", tools: [spawn])
58
+ ```
59
+
60
+ The generated `spawn_agent` tool asks for a complete task and system
61
+ instructions. The model may select only tools in the pool and only model IDs in
62
+ `models:`. Without an explicit model, an inline child inherits the supplied
63
+ provider object. A background child records that provider's model identity and
64
+ the Runtime factory reconstructs its provider inside the worker.
65
+
66
+ Do not place `spawn_agent` inside its own tool pool. Mistri rejects that host
67
+ configuration.
68
+
69
+ ### Typed workers
70
+
71
+ Definitions let the host own a worker's base prompt and its default tools and
72
+ model:
73
+
74
+ ```ruby
75
+ types = {
76
+ "researcher" => Mistri::Definition.load("agents/researcher.md"),
77
+ "reviewer" => Mistri::Definition.load("agents/reviewer.md"),
78
+ }
79
+
80
+ spawn = Mistri::SubAgent.spawner(
81
+ provider: child_provider,
82
+ tools: [fetch_page, search_catalog, inspect_record],
83
+ types: types,
84
+ models: ["claude-haiku-4-5"],
85
+ )
86
+ ```
87
+
88
+ A typed worker always begins with its Definition's rendered prompt. The model
89
+ may append focus instructions, choose another subset from the host's tool pool,
90
+ and select another model from the explicit allowlist. Without those overrides,
91
+ the Definition's declared tool names and model are the defaults. Definitions
92
+ are checked when the spawner is built, so a missing pool tool or unresolved
93
+ placeholder fails before a model can spawn. Use a fixed `Mistri::SubAgent` when
94
+ the worker's tools and model must not be model-selectable.
95
+
96
+ `general-purpose` remains the built-in open type and requires model-written
97
+ instructions.
98
+
99
+ ## Execution modes
100
+
101
+ Without a dispatcher, a child runs inline inside the tool call. The parent waits
102
+ for the report, and its abort signal cascades to the child.
103
+
104
+ Add a dispatcher to expose `mode: "background"`:
105
+
106
+ ```ruby
107
+ runtime_factory = lambda do |spec|
108
+ workspace = HostWorkspaces.for_child(spec.fetch("session_id"))
109
+ provider = HostModels.build(spec.fetch("model"))
110
+ tools = HostTools.build(spec.fetch("tool_names"), workspace: workspace)
111
+
112
+ Mistri::SubAgent::Runtime.new(
113
+ provider: provider,
114
+ system: HostAgents.system_for(spec),
115
+ tools: tools,
116
+ cleanup: -> { HostAgents.release(provider: provider, tools: tools) },
117
+ )
118
+ end
119
+
120
+ tools = Mistri::SubAgent.pack(
121
+ provider: child_provider,
122
+ tools: [fetch_page, search_catalog],
123
+ dispatcher: Mistri::Dispatchers::Thread.new,
124
+ runtime_factory: runtime_factory,
125
+ )
126
+ ```
127
+
128
+ `pack` returns the spawn tool plus `list_agents`, `read_agent`, `steer_agent`,
129
+ and `stop_agent`. A dispatcher always requires `runtime_factory:`. The static
130
+ provider, tool pool, types, and model allowlist declare what the model may
131
+ request. The factory constructs what one background child may actually use.
132
+
133
+ `Mistri::SubAgent::Runtime` holds the provider, system prompt, exact tools, an
134
+ optional `cleanup:` callable, and safe Agent options for one execution:
135
+ `budget`, `max_concurrency`, `transform_context`, `compaction`, `retries`,
136
+ `before_tool`, `after_tool`, and application `context`. `skills:` is refused in
137
+ a background Runtime because Agent would add a `read_skill` tool outside the
138
+ durable grant. Lifecycle keywords such as `session`, `task`, `signal`, and
139
+ `emit` are never Agent options; the child boundary owns them. The same option
140
+ validation applies to fixed specialists and Spawner. Spawner-level Agent
141
+ options still apply only to inline children; put a background child's options
142
+ on its Runtime.
143
+
144
+ Mistri calls the factory inside the worker after ruling out a finished
145
+ redelivery. While a configured child lease remains live, it also rules out an
146
+ ordinary concurrent delivery first. It then requires the runtime provider's
147
+ model and the unique runtime tool names to match the versioned dispatch spec
148
+ exactly.
149
+ Missing, additional, renamed, duplicated, or statically approval-gated runtime
150
+ tools fail the child before a provider call or tool side effect. Runtime tools
151
+ are reordered to the durable grant so provider prompt order is deterministic.
152
+ Mistri calls `cleanup` exactly once for a Runtime the factory returned,
153
+ including after validation or execution failure. A cleanup failure does not
154
+ replace an active primary error; by itself it raises after the terminal result
155
+ and report are durable. Use it to release per-child providers, MCP clients, or
156
+ other connections. Mistri does not guess which objects the host owns.
157
+
158
+ The model has no workspace selector. Derive resource scope from trusted host
159
+ state and stable identifiers such as the child Session ID, not from its name,
160
+ task, or instructions. Reconstructing a Ruby object is not itself isolation:
161
+ the host must decide whether two runtimes use separate directories, database
162
+ rows, credentials, MCP clients, or other backends. Use the same deterministic
163
+ child scope on a queue retry rather than creating a random empty workspace.
164
+
165
+ ### Thread dispatcher
166
+
167
+ `Mistri::Dispatchers::Thread` starts a Ruby thread and returns a receipt to the
168
+ parent immediately. The runtime factory executes on that thread. The host must
169
+ still construct fresh or deliberately shared dependencies; a Proc can close
170
+ over any object, and Mistri does not pretend to inspect its closure. The Thread
171
+ dispatcher is useful for development and short work in a process whose
172
+ lifetime the host controls. It is not a durable job queue.
173
+
174
+ ### Queue dispatcher
175
+
176
+ A production host can dispatch the serializable spec to its queue:
177
+
178
+ ```ruby
179
+ dispatcher = lambda do |spec, _in_process_runner|
180
+ RunMistriChildJob.perform_later(spec)
181
+ end
182
+ ```
183
+
184
+ The spec is a deeply owned, immutable, bounded JSON Hash containing a spec
185
+ version, child and parent Session IDs, worker type, model-written instructions,
186
+ task, exact tool names, and model ID. The identical canonical spec is stored in
187
+ the child Session before dispatch. `run_dispatched` compares the complete queue
188
+ copy with that durable grant before checking a finished result, constructing a
189
+ Runtime, or routing a report. A changed grant, task, model, name, or parent ID
190
+ raises `Mistri::DispatchGrantError` and leaves the legitimate child untouched.
191
+ The unchanged queue item cannot heal: configure the job system to discard it
192
+ and alert an operator. The class is reserved for Mistri's stored-grant binding;
193
+ a runtime factory must not use it as an application signal. Versioned specs
194
+ reject unknown fields.
195
+
196
+ Unversioned 0.5 queue specs remain executable because their child entries did
197
+ not store a comparable grant. That compatibility cannot retroactively prove
198
+ their queue copy or report routing. Drain or inspect those jobs before relying
199
+ on the versioned boundary.
200
+
201
+ The spec contains no providers, tools, workspaces, credentials, or callbacks.
202
+ If queue execution needs a tenant or account ID, put that trusted identifier
203
+ beside the spec in the host's job envelope and reauthorize it in the worker; do
204
+ not add fields to the spec or put live application objects inside it.
205
+
206
+ The job invokes the same host registry locally, then enters the shared child
207
+ lifecycle:
208
+
209
+ ```ruby
210
+ Mistri::SubAgent.run_dispatched(
211
+ spec,
212
+ store: mistri_store,
213
+ emit: event_sink,
214
+ runtime_factory: HostAgents.method(:runtime_for),
215
+ )
216
+ ```
217
+
218
+ The factory itself is host code and is never serialized. Existing jobs may
219
+ continue to pass directly reconstructed `provider:`, `system:`, and `tools:` to
220
+ `run_dispatched`; Mistri applies the same exact model and capability checks.
221
+ Prefer the factory form because it runs after the stored terminal check and,
222
+ with a configured lock adapter, successful child-lease acquisition. It avoids
223
+ constructing credentials, connections, or workspaces for a finished delivery
224
+ and for an ordinary concurrent delivery while that lease remains live.
225
+
226
+ An exception raised while the queue's factory is constructing dependencies is
227
+ retryable by default: `run_dispatched` releases the lease, leaves no terminal,
228
+ and re-raises so the queue can retry. Runtime contract mismatches are terminal.
229
+ On a queue's final attempt, pass `retry_factory_errors: false` to record and
230
+ report the construction failure before the exception re-raises. The flag does
231
+ not decide whether the job system retries or discards the exception. The
232
+ in-process runner already uses terminal behavior because Thread and Inline have
233
+ no durable retry owner.
234
+
235
+ The factory boundary applies to dispatched children. Inline spawns and fixed
236
+ specialists continue to use the provider and Tool objects supplied by the host;
237
+ parallel inline calls may therefore share whatever those objects close over.
238
+ That is deliberate host policy, not an isolation guarantee.
239
+
240
+ ## Reports and control
241
+
242
+ A background spawn returns a truthful receipt based on the child's stored
243
+ status. When the child reaches a terminal state, it writes its result to its own
244
+ session and delivers one typed report into the parent inbox.
245
+
246
+ An inactive queued or interrupted cancellation owns that durable inbox delivery
247
+ while it holds the child lease, so the parent receives the stopped report even
248
+ if the queue never starts or retries the job. The callback-only
249
+ `:subagent_report` event is not emitted for that host-initiated path because
250
+ `Child#stop` has no event sink; the durable parent inbox is authoritative.
251
+ If that cross-session inbox append raises, the stopped terminal remains
252
+ durable. Repeating `Child#stop` or `stop_agent` retries the report under the
253
+ child lease and sequentially deduplicates an earlier successful delivery.
254
+
255
+ The parent folds that report at its next turn boundary as a labeled message.
256
+ The `:subagent_report` event closes the worker's live UI lane. Duplicate report
257
+ delivery is dropped by child Session ID when redeliveries are sequential. The
258
+ current check is not an atomic uniqueness constraint, so a queue must serialize
259
+ delivery or deduplicate concurrent attempts in host storage.
260
+
261
+ Host code and the management tools use the same `Mistri::Child` facade:
262
+
263
+ ```ruby
264
+ child = parent_session.children.first
265
+
266
+ child.status # :queued, :running, :interrupted, :done, :stopped, :failed
267
+ child.report # terminal report when done
268
+ child.error # terminal error when failed
269
+ child.transcript(tail: 20) # presentation-oriented recent entries
270
+ child.say("Check pricing too") # queues a steer
271
+ child.stop # durable if inactive, cooperative if running
272
+ ```
273
+
274
+ `read_agent` with `wait: true` waits for a terminal, including across an
275
+ interrupted worker's later queue retry or cancellation. Abort and timeout still
276
+ end the wait in band.
277
+
278
+ Stopping requires a lock adapter. A queued or interrupted child that has no
279
+ current lease owner is terminalized and reported immediately under a lease. If
280
+ a runner already owns the lease, the cross-process flag requests its
281
+ cooperative abort instead.
282
+
283
+ Child events forwarded into the parent stream carry an `origin` such as
284
+ `Corgi#ab12cd34`. `session.transcript(include_children: true)` uses the same
285
+ labels when rebuilding lanes from the store.
286
+
287
+ Like `Session#transcript`, a child transcript only strips inline image bytes. It
288
+ is not an authorization or redaction boundary; authorize and redact it before
289
+ exposing it.
290
+
291
+ ## Provider concurrency
292
+
293
+ Several spawn calls in one parent turn are tool calls and can begin
294
+ concurrently. Actual model requests depend on provider instances:
295
+
296
+ - Inline calls sharing one built-in provider object serialize through that
297
+ provider's transport.
298
+ - A background Runtime factory decides whether children receive independent or
299
+ deliberately shared providers. Distinct instances are required when their
300
+ model calls must overlap.
301
+ - An inline typed Definition with its own model builds a provider for that
302
+ worker. A background typed worker records the model identity and relies on
303
+ the Runtime factory to construct it.
304
+
305
+ This distinction avoids promising network fan-out when only tool scheduling is
306
+ parallel.
307
+
308
+ ## Approval
309
+
310
+ A child cannot suspend and wait for a human. It must return a report to its
311
+ parent.
312
+
313
+ Mistri rejects statically approval-gated tools when a SubAgent or Spawner is
314
+ built. A predicate gate cannot always be detected statically; if one triggers at
315
+ runtime, Mistri denies and settles the call, fails the child, and leaves no open
316
+ approval.
317
+
318
+ Gate the delegation itself instead:
319
+
320
+ ```ruby
321
+ reviewer = Mistri::SubAgent.new(
322
+ name: "external_reviewer",
323
+ description: "Sends material to the external review service.",
324
+ provider: reviewer_provider,
325
+ tools: [external_review],
326
+ needs_approval: true,
327
+ )
328
+ ```
329
+
330
+ ## Lock adapters
331
+
332
+ Cross-process liveness and stop requests require a shared lock adapter. Configure
333
+ one at boot:
334
+
335
+ ```ruby
336
+ # One process, including the thread dispatcher:
337
+ Mistri.locks = Mistri::Locks::Memory.new
338
+ ```
339
+
340
+ Rails cache support is optional and must be required explicitly:
341
+
342
+ ```ruby
343
+ require "mistri/locks/rails_cache"
344
+
345
+ Mistri.locks = Mistri::Locks::RailsCache.new(cache: Rails.cache)
346
+ ```
347
+
348
+ The cache must implement atomic `unless_exist` writes and be shared by every
349
+ worker process. A local memory cache is not a cross-process adapter.
350
+
351
+ A custom adapter implements:
352
+
353
+ ```text
354
+ acquire(key, ttl:) -> boolean
355
+ renew(key, ttl:)
356
+ release(key)
357
+ held?(key) -> boolean
358
+ set_flag(key, ttl:)
359
+ flag?(key) -> boolean
360
+ clear_flag(key)
361
+ ```
362
+
363
+ The current lease is a best-effort duplicate-suppression and liveness signal,
364
+ not an ownership token or exactly-once fence. It stores no holder identity. A
365
+ process stalled beyond the TTL can resume after another process acquires the
366
+ same key, and unconditional renewal or release cannot distinguish those
367
+ generations.
368
+
369
+ Queue jobs must therefore keep child work idempotent or reconcilable. A held
370
+ lease suppresses an ordinary redelivery while the first worker is healthy; an
371
+ expired lease allows a retry of a child that appears interrupted. A finished
372
+ child's terminal entry makes a later matching delivery a no-op. Calling
373
+ `Child#stop` while the child is interrupted competes for the lease and, when it
374
+ wins, replaces that possible retry with a durable stopped terminal.
375
+
376
+ Without an adapter:
377
+
378
+ - inline and dispatched work can still run;
379
+ - a non-terminal started child reads `:running`, because no shared liveness
380
+ signal exists;
381
+ - `Child#stop` cannot send a cross-process request;
382
+ - queue redeliveries receive no lease-based suppression.
383
+
384
+ ## Capacity is advisory
385
+
386
+ `max_children` counts the parent's currently live children before spawning. It
387
+ is a useful model-facing limit, but the read-then-create sequence is not an
388
+ atomic distributed admission control. Concurrent parent runners are already
389
+ outside the Session execution contract, and independent callers can race the
390
+ count.
391
+
392
+ If worker capacity is a hard operational limit, enforce it atomically in the
393
+ host queue, database, or scheduler. Keep `max_children` as the in-band guidance
394
+ the model sees.
395
+
396
+ ## Related guides
397
+
398
+ - [Sessions and control](sessions.md)
399
+ - [Tool contracts](tool-contracts.md)
400
+ - [Reliability](reliability.md)
401
+ - [Upgrading](../UPGRADING.md)