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.
Files changed (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +480 -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 +11 -4
  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 +190 -5
  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
@@ -0,0 +1,450 @@
1
+ # Reliability
2
+
3
+ Mistri makes the execution boundary explicit. It does not turn a model call,
4
+ network, process, database, and external side effect into one transaction.
5
+
6
+ The core rule is simple: the gem owns mechanism; the host owns policy. Mistri
7
+ validates and records what it can know. The application supplies authorization,
8
+ runner serialization, idempotency, reconciliation, queue guarantees, and UI.
9
+
10
+ ## Result and error model
11
+
12
+ Every run returns `Mistri::Result` unless a separate host contract must raise:
13
+
14
+ ```ruby
15
+ result.completed?
16
+ result.awaiting_approval?
17
+ result.aborted?
18
+ result.stopped_by_budget?
19
+ result.errored?
20
+ result.handed_off?
21
+ ```
22
+
23
+ `result.text` reads the final assistant message. `result.output` is the parsed,
24
+ validated value from task mode. `result.usage` sums provider attempts and
25
+ compaction calls made during that invocation.
26
+
27
+ Failures the model can correct stay in band. Unknown tools, invalid arguments,
28
+ policy blocks, expected tool failures, handler exceptions, and timeouts become
29
+ typed tool results. Valid sibling calls may still execute.
30
+
31
+ Built-in provider failures, including exhausted retries and provider response
32
+ limits, finish as `result.errored?`. Configuration and Session contract failures,
33
+ final task validation, and unknown-cost reconciliation raise a `Mistri::Error`
34
+ subclass. Store and subscriber callbacks can propagate their own exceptions.
35
+
36
+ Direct MCP Client failures raise. A tool bridged through `Mistri::MCP.tools`
37
+ runs inside the tool executor, so its Client exception, including ambiguous
38
+ delivery, becomes an error-marked tool result. The transport has not replayed
39
+ the call; the model may still choose to issue a new one.
40
+
41
+ Rescue `Mistri::Error` for the gem's public error family. Do not rescue
42
+ `StandardError` around the entire run and silently convert programming or
43
+ subscriber bugs into model output.
44
+
45
+ ## Side-effect outcomes can be ambiguous
46
+
47
+ Only one Agent may actively call `run` or `resume` for a Session at a time. The
48
+ store can accept independent approval, steer, and worker-report appends, but
49
+ active runners are a host scheduling concern.
50
+
51
+ Even with one runner, this gap remains:
52
+
53
+ 1. Mistri records that a call is ready.
54
+ 2. The handler commits an external write.
55
+ 3. The process, subscriber, or store fails before the result append.
56
+
57
+ On replay, Mistri can prove that the call has no durable result. It cannot prove
58
+ whether step 2 happened. The Session synthesizes an interrupted error so the
59
+ provider transcript remains pairable and tells the caller to verify effects.
60
+
61
+ Build write tools around a domain idempotency key or reconciliation handle:
62
+
63
+ ```ruby
64
+ Mistri::Tool.define(
65
+ "send_gift", "Sends one gift order.",
66
+ schema: lambda {
67
+ string :order_id, "Host-issued idempotency key", required: true
68
+ string :recipient_id, "Recipient ID", required: true
69
+ },
70
+ ) do |args|
71
+ GiftOrders.create_once!(
72
+ idempotency_key: args.fetch("order_id"),
73
+ recipient_id: args.fetch("recipient_id"),
74
+ )
75
+ end
76
+ ```
77
+
78
+ The exact mechanism belongs to the application and its downstream system. For
79
+ some APIs it is an idempotency header; for a database it may be a unique key and
80
+ transaction; for an irreversible operation it may require a reconciliation
81
+ screen instead of automatic retry.
82
+
83
+ Approval reduces authorization risk but does not make execution exactly once.
84
+ Mistri guarantees neither exactly-once execution nor eventual at-least-once
85
+ delivery: a call may run once, more than once after host or model retry, or not
86
+ at all. Durable facts tell the host what is known; idempotency and reconciliation
87
+ decide what to do next.
88
+
89
+ ## Provider retries
90
+
91
+ Transient provider failures retry with jittered exponential backoff by default:
92
+
93
+ ```ruby
94
+ policy = Mistri::RetryPolicy.new(
95
+ attempts: 3, # retries; up to four total requests
96
+ base: 1.0,
97
+ max_delay: 30.0,
98
+ )
99
+
100
+ agent = Mistri.agent("claude-opus-4-8", retries: policy)
101
+ ```
102
+
103
+ Set `retries: false` to disable the Agent retry loop.
104
+
105
+ Rate limits, overload, timeouts, selected server statuses, dropped or truncated
106
+ streams, and empty completions are retryable. Authentication errors, invalid
107
+ requests, content-policy refusals, unsafe configuration, and host bugs fail
108
+ fast. A provider `Retry-After` value is honored within `max_delay`.
109
+
110
+ Each retry sends unchanged durable history. Failed attempts are not appended as
111
+ conversation messages, but their usage and retry record remain available for
112
+ accounting.
113
+
114
+ The subscriber receives `:retry` with `attempt`, `max_attempts`, and `delay`.
115
+ Only the accepted provider attempt's `:done` or `:error` event is released, so a
116
+ recovered retry does not flash a terminal failure and then retract it.
117
+
118
+ MCP `tools/call` is intentionally different. Once sent, a missing response is
119
+ an ambiguous side effect and is not replayed by the Client. The Agent bridge
120
+ returns that exception as an error-marked tool result. See
121
+ [Ambiguous tool delivery](mcp.md#ambiguous-tool-delivery).
122
+
123
+ ## Events and sinks
124
+
125
+ Every built-in provider attempt emits:
126
+
127
+ 1. `:start` with an empty immutable assistant snapshot;
128
+ 2. matched start and end events for each recognized text, thinking, or tool-call
129
+ block, with zero or more delta events as content arrives.
130
+
131
+ A retried attempt's terminal event is suppressed and `:retry` is its visible
132
+ boundary. Only the accepted attempt releases exactly one `:done` or `:error`.
133
+ Block deltas from the failed attempt may already have reached the subscriber;
134
+ on `:retry` or the next `:start`, a UI must reset or replace that ephemeral
135
+ attempt instead of appending it to the next one. Only the accepted Message is
136
+ persisted.
137
+
138
+ A custom provider must emit the same lifecycle when host subscribers depend on
139
+ streaming; the Agent does not synthesize provider events around `stream`.
140
+
141
+ Those terminal events end one provider turn, not necessarily the Agent run. If
142
+ the turn calls tools, the Agent emits tool lifecycle events, appends results,
143
+ and starts another provider turn.
144
+
145
+ Loop-level events are:
146
+
147
+ - `:tool_started` when a resolved call commits to handler invocation;
148
+ - `:tool_result` after settlement, with a required `tool_error` boolean;
149
+ - `:approval_needed` when a prepared call parks;
150
+ - `:compacting` before summary work and `:compaction` with the committed
151
+ summary in `content`;
152
+ - `:retry` before provider backoff;
153
+ - `:subagent_report` when a running background child reaches a terminal state;
154
+ an inactive `Child#stop` persists the report without this callback because it
155
+ has no event sink.
156
+
157
+ Event types form an extensible union. Handle the types the application uses and
158
+ ignore the rest:
159
+
160
+ ```ruby
161
+ agent.run(input) do |event|
162
+ case event.type
163
+ when :text_delta
164
+ stream_text(event.delta)
165
+ when :retry
166
+ show_retry(event.attempt, event.max_attempts, event.delay)
167
+ when :tool_started
168
+ mark_tool_running(event.tool_call)
169
+ when :tool_result
170
+ settle_tool(event.tool_call, failed: event.tool_error)
171
+ when :approval_needed
172
+ request_human_decision(event.tool_call)
173
+ when :done, :error
174
+ settle_provider_turn(event.message)
175
+ end
176
+ end
177
+ ```
178
+
179
+ `:tool_started` and handler progress originate on tool worker threads. Calls
180
+ within one Agent are serialized before reaching the subscriber, but background
181
+ children can emit alongside the parent. A custom sink must be thread-safe.
182
+
183
+ `Mistri::Sinks::Coalesced` is origin-aware and thread-safe. It merges bursts of
184
+ text, thinking, and tool-argument deltas by event type, content index, and
185
+ origin, then flushes before any other event:
186
+
187
+ ```ruby
188
+ raw = Mistri::Sinks::SSE.new(io)
189
+ sink = Mistri::Sinks::Coalesced.new(raw, interval: 0.05)
190
+
191
+ agent.run(input, &sink)
192
+ ```
193
+
194
+ `Mistri::Sinks::SSE` accepts any object with `write` and optionally `flush`.
195
+ `Mistri::Sinks::ActionCable` resolves Action Cable lazily or accepts an explicit
196
+ broadcaster. None of these require a Railtie.
197
+
198
+ Subscriber callbacks are not an error-isolation boundary. An exception can
199
+ abort an operation and, after a handler starts, create the same unknown-outcome
200
+ gap as a process crash. Do not raise intentionally from a sink; keep it small,
201
+ observable, thread-safe, and independently retryable where possible.
202
+
203
+ Across Mistri's synchronous delivery boundaries, the exact subscriber exception
204
+ object propagates to the caller. The gem does not retry it or reinterpret it as
205
+ a provider, transport, hook, tool, parse, or compaction failure, even when its
206
+ class belongs to one of those error families. This applies through nested
207
+ inline sub-agents as well as the top-level Agent. A custom provider must likewise
208
+ let its event callback escape rather than folding it into a provider result. A
209
+ background child emitting after its spawn call returned has no synchronous
210
+ caller to raise into. A callback that aborts its execution records the original
211
+ failure class in the failed child terminal and dispatcher diagnostic. If only
212
+ the post-terminal `:subagent_report` callback fails, the completed child and
213
+ already-delivered parent inbox report remain durable; the dispatcher diagnoses
214
+ the callback failure.
215
+
216
+ Propagation does not roll back durable facts. `:tool_result` and
217
+ `:approval_needed` run after their Session entries append, and `:compaction`
218
+ runs after its checkpoint appends. Provider terminal events run before the
219
+ assistant Message append. `:tool_started` runs before handler invocation, while
220
+ a handler progress failure may leave an external side effect with no durable
221
+ result. Reconcile from the Session and the tool's domain idempotency key rather
222
+ than retrying solely because the sink failed.
223
+
224
+ ## Stopping
225
+
226
+ `Mistri::AbortSignal` is an in-process, thread-safe, one-way latch:
227
+
228
+ ```ruby
229
+ signal = Mistri::AbortSignal.new
230
+
231
+ runner = Thread.new do
232
+ agent.run("Draft a long report.", signal: signal)
233
+ end
234
+
235
+ signal.abort!("user cancelled")
236
+ runner.join
237
+ ```
238
+
239
+ The provider transport registers a callback that closes an in-flight socket,
240
+ so abort does not wait for the read timeout. The Agent checks the signal at safe
241
+ boundaries and persists a replay-valid partial turn.
242
+
243
+ Tool handlers are application code. A handler doing long work should inspect
244
+ `context.signal&.aborted?` at safe points or arrange its own cancellable I/O.
245
+ Cancellation is cooperative; Mistri does not kill arbitrary handler code.
246
+
247
+ The signal object cannot be shared across processes. Cross-process child stop
248
+ uses a shared lock adapter and a cooperative flag, as described in
249
+ [Sub-agent lock adapters](sub-agents.md#lock-adapters). Top-level session runner
250
+ coordination remains host policy.
251
+
252
+ ## Budgets
253
+
254
+ Budgets are opt-in:
255
+
256
+ ```ruby
257
+ budget = Mistri::Budget.new(
258
+ turns: 20,
259
+ tokens: 500_000,
260
+ cost_usd: 5.00,
261
+ wall_clock: 300,
262
+ )
263
+ ```
264
+
265
+ Limits are checked between model-visible turns. A turn already in progress,
266
+ including its retry attempts, completes before the next check. Every budget is
267
+ therefore a soft ceiling and may exceed its configured value by one turn.
268
+
269
+ ### Cost budgets
270
+
271
+ Unknown price is not zero. Constructing a cost-budgeted Agent requires a
272
+ catalogued model, a list-priced origin, and deterministic standard-tier policy.
273
+
274
+ Anthropic and OpenAI require the tier explicitly:
275
+
276
+ ```ruby
277
+ anthropic = Mistri.agent(
278
+ "claude-opus-4-8",
279
+ budget: Mistri::Budget.new(cost_usd: 5.00),
280
+ provider_options: { service_tier: "standard_only" },
281
+ )
282
+
283
+ openai = Mistri.agent(
284
+ "gpt-5.6-terra",
285
+ budget: Mistri::Budget.new(cost_usd: 5.00),
286
+ provider_options: { service_tier: "default" },
287
+ )
288
+ ```
289
+
290
+ Gemini's omitted tier is standard by contract. A reported Flex or Priority
291
+ tier remains unpriced by the standard catalog.
292
+
293
+ If a cost-budgeted request returns usage that cannot be priced, Mistri raises
294
+ `Mistri::BudgetError` and does not retry under false certainty. The exception
295
+ exposes `usage` and `provider_message`; the Session also records an
296
+ `unpriced_attempt` entry for reconciliation.
297
+
298
+ `result.usage.cost.known?` distinguishes known accounting from an unavailable
299
+ estimate. Dollar values use published standard paid direct-API list prices
300
+ embedded and versioned with the installed gem; they are not fetched live.
301
+ Contracted pricing, regional uplifts, taxes, and separately billed provider
302
+ tools are outside the estimate. Gemini free-tier usage is conservatively valued
303
+ at the paid rate.
304
+
305
+ Custom provider origins disable catalog pricing by default. Pass
306
+ `catalog_pricing: true` only when that route bills exactly the same published
307
+ rates. This flag never changes service-tier policy.
308
+
309
+ ## Inbound response limits
310
+
311
+ Provider and MCP JSON bodies, individual SSE lines, and stdio records default
312
+ to 8 MiB per record. A stream can exceed 8 MiB in total while every atomic
313
+ record remains within the boundary.
314
+
315
+ Successful bodies are counted while reading. Declared oversized bodies fail
316
+ before their body is read. Error responses retain at most a 500-byte valid UTF-8
317
+ preview. Requests use identity encoding so compressed expansion cannot evade
318
+ the limit.
319
+
320
+ Completed SSE JSON records receive a bounded structural scan before parsing.
321
+ Fragmented Anthropic and OpenAI tool arguments also carry an aggregate 8 MiB
322
+ limit across deltas; their partial preview refreshes on a bounded schedule while
323
+ raw delta events continue.
324
+
325
+ Set `max_record_bytes:` through `provider_options:` only after deciding the
326
+ larger trusted boundary is acceptable:
327
+
328
+ ```ruby
329
+ agent = Mistri.agent(
330
+ "gemini-3.1-pro-preview",
331
+ provider_options: { max_record_bytes: 16 * 1024 * 1024 },
332
+ )
333
+ ```
334
+
335
+ The byte boundary is configurable. Fixed structural ceilings protect the JSON
336
+ allocation shape and are not raised by that option.
337
+
338
+ ## Provider input and options
339
+
340
+ Images are immutable content blocks:
341
+
342
+ ```ruby
343
+ photo = Mistri::Content::Image.from_bytes(
344
+ File.binread("chart.png"),
345
+ mime_type: "image/png",
346
+ )
347
+
348
+ agent.run("What trend does this chart show?", images: [photo])
349
+ ```
350
+
351
+ `from_data_uri` accepts the `data:<mime>;base64,<data>` shape commonly produced
352
+ by a canvas or upload. The host still owns media allowlists, decoded-size
353
+ limits, and content inspection.
354
+
355
+ Provider constructor settings go through `provider_options:`:
356
+
357
+ ```ruby
358
+ Mistri.agent(
359
+ "gpt-5.6",
360
+ provider_options: { reasoning: { summary: "auto" } },
361
+ )
362
+
363
+ Mistri.agent(
364
+ "claude-opus-4-8",
365
+ provider_options: { cache: false },
366
+ )
367
+ ```
368
+
369
+ Provider-specific per-turn overrides belong on a directly constructed
370
+ provider's `stream` contract. The Agent intentionally exposes a stable common
371
+ loop rather than mirroring every provider option as a top-level keyword.
372
+
373
+ ## Custom providers
374
+
375
+ A provider used by `Mistri::Agent` exposes a model ID and responds to `stream`:
376
+
377
+ ```ruby
378
+ message = provider.stream(
379
+ messages: messages,
380
+ system: system,
381
+ tools: tool_specs,
382
+ signal: signal,
383
+ **overrides,
384
+ ) do |event|
385
+ # Mistri::Event instances
386
+ end
387
+ ```
388
+
389
+ `provider.model` is required by default compaction and cost-policy checks.
390
+ Disable compaction explicitly only when a custom provider cannot describe a
391
+ model context window.
392
+
393
+ It returns an assistant `Mistri::Message`. A completed tool call must be a
394
+ `Mistri::ToolCall` with:
395
+
396
+ - a non-empty UTF-8 String ID unique for new calls in the Session;
397
+ - a non-empty UTF-8 String name;
398
+ - an owned JSON object for valid arguments, or `arguments_error` for malformed
399
+ encoded input;
400
+ - non-empty UTF-8 signature and provider call ID strings when present.
401
+
402
+ A malformed completed envelope rejects the whole provider attempt before the
403
+ assistant message persists or any sibling tool runs. Do not invent missing IDs
404
+ inside the Agent; the provider owns its wire correlation.
405
+
406
+ Custom providers may return `native_output_schema(schema)` when they can enforce
407
+ the requested task contract. Returning nil leaves the prompt and local
408
+ validation path intact.
409
+
410
+ To support cost budgets, return true from `prices_usage?` and attach a known
411
+ cost to every response, commonly with `Mistri::Usage#with_cost`. Missing or
412
+ partial pricing then fails closed at runtime.
413
+
414
+ Study `Mistri::Providers::Fake` for a hermetic implementation and the built-in
415
+ assemblers for strict streaming lifecycle behavior.
416
+
417
+ ## Testing strategy
418
+
419
+ The default suite uses the Fake provider, recorded wire fixtures, local stub
420
+ servers, and no network:
421
+
422
+ ```console
423
+ $ bundle exec rake test
424
+ $ bundle exec rubocop
425
+ ```
426
+
427
+ Gated live tests exercise exact provider regressions when keys are present:
428
+
429
+ ```console
430
+ $ MISTRI_LIVE=1 bundle exec rake test
431
+ ```
432
+
433
+ The integration harness runs critical end-to-end scenarios across an Anthropic,
434
+ OpenAI, and Gemini model by default:
435
+
436
+ ```console
437
+ $ bundle exec rake integration
438
+ $ MISTRI_INTEGRATION_MODELS=claude-opus-4-8 bundle exec rake integration
439
+ ```
440
+
441
+ Keys load from the gitignored `.env.development.local`. A missing key skips that
442
+ provider; inspect the test output before claiming a full three-provider run.
443
+
444
+ ## Related guides
445
+
446
+ - [Tool contracts](tool-contracts.md)
447
+ - [Sessions and control](sessions.md)
448
+ - [Sub-agents](sub-agents.md)
449
+ - [MCP](mcp.md)
450
+ - [Upgrading](../UPGRADING.md)