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/README.md CHANGED
@@ -7,189 +7,189 @@
7
7
 
8
8
  <p align="center"><strong>mistri</strong>, the agent harness for Ruby applications.</p>
9
9
 
10
- <p align="center"><a href="https://mistri.sh">mistri.sh</a> · <a href="https://mistri.sh/docs/getting-started/">docs</a></p>
10
+ <p align="center"><a href="https://mistri.sh">mistri.sh</a> · <a href="docs/README.md">documentation</a> · <a href="UPGRADING.md">upgrading</a></p>
11
11
 
12
12
  <p align="center">
13
13
  <a href="https://rubygems.org/gems/mistri"><img alt="Gem Version" src="https://img.shields.io/gem/v/mistri"></a>
14
14
  <a href="https://github.com/mcheemaa/mistri/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/mcheemaa/mistri/actions/workflows/ci.yml/badge.svg"></a>
15
15
  <a href="https://codecov.io/gh/mcheemaa/mistri"><img alt="Coverage" src="https://img.shields.io/codecov/c/github/mcheemaa/mistri"></a>
16
16
  <a href="mistri.gemspec"><img alt="Ruby >= 3.2" src="https://img.shields.io/badge/ruby-%3E%3D%203.2-CC342D"></a>
17
- <a href="Gemfile"><img alt="Runtime dependencies: zero" src="https://img.shields.io/badge/runtime_deps-0-brightgreen"></a>
17
+ <a href="mistri.gemspec"><img alt="Runtime dependencies: zero" src="https://img.shields.io/badge/runtime_deps-0-brightgreen"></a>
18
18
  <a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-blue.svg"></a>
19
19
  </p>
20
20
 
21
21
  A mistri (Urdu: مستری) is the fixer: the skilled tradesperson who actually
22
- gets it done. This one lives inside your app, not in a terminal. It runs the
23
- model loop, executes tools, streams every event, persists sessions to your
24
- own database, and pauses for a human when a tool needs approval, all with
25
- zero runtime gem dependencies.
22
+ gets it done. Mistri is an application-embedded agent runtime for Ruby. It owns
23
+ the model loop, safe tool boundary, event stream, and append-only session
24
+ record. Your application owns authorization, policy, presentation, and the
25
+ semantics of its side effects.
26
26
 
27
- ```ruby
28
- require "mistri"
29
-
30
- weather = Mistri::Tool.define(
31
- "get_weather", "Current weather for a city.",
32
- schema: -> { string :city, "City name", required: true },
33
- ) do |args|
34
- Weather.for(args["city"])
35
- end
36
-
37
- agent = Mistri.agent("claude-opus-4-8", tools: [weather]) # reads ANTHROPIC_API_KEY
38
-
39
- agent.run("What should I wear in Lahore today?") do |event|
40
- print event.delta if event.type == :text_delta
41
- end
42
- ```
43
-
44
- ## Why Mistri
27
+ Mistri works in plain Ruby applications, including Rails, Sinatra, Hanami, jobs,
28
+ and services. Rails integrations are optional. The gem declares zero runtime
29
+ dependencies.
45
30
 
46
- - **Built for applications.** Sessions are durable, append-only records in
47
- your own store. Runs stop, resume, steer, and compact from any process.
48
- - **Fire-and-forget human approval.** A gated tool suspends the run and
49
- returns immediately. The approval can arrive two days later from a bare
50
- web request; nothing sleeps waiting.
51
- - **Three providers, frontier-deep.** Anthropic, OpenAI, and Gemini, each
52
- streamed natively with thinking, prompt caching, parallel tool calls, and
53
- constrained JSON output. One message model across all three.
54
- - **Zero runtime dependencies.** Plain Ruby all the way down.
55
- - **Verified against real APIs.** A live integration harness runs every
56
- feature end to end on every provider (`rake integration`).
31
+ ## Start in sixty seconds
57
32
 
58
- ## Install
33
+ Add Mistri to your bundle:
59
34
 
60
35
  ```ruby
61
36
  gem "mistri"
62
37
  ```
63
38
 
64
- ## Sixty-second start
39
+ Then define a tool and run an agent:
65
40
 
66
41
  ```ruby
67
- agent = Mistri.agent("claude-opus-4-8")
68
- result = agent.run("Name three Ruby web frameworks.")
69
- puts result.text
70
- ```
71
-
72
- `Mistri.agent` infers the provider from the model id (`claude-*`, `gpt-*`,
73
- `gemini-*`) and reads the matching key (`ANTHROPIC_API_KEY`,
74
- `OPENAI_API_KEY`, `GEMINI_API_KEY`); pass `api_key:` to set it explicitly.
75
- Every run returns a `Result`: `completed?`, `awaiting_approval?`,
76
- `aborted?`, `errored?`, with `text` and (for tasks) `output`.
77
-
78
- ## Tools
79
-
80
- A tool is a name, a description, an argument schema, and a block. The block
81
- returns a String, a Hash (sent as JSON), or content such as an image. The
82
- agent calls tools, feeds results back, and loops until the model answers;
83
- independent calls in a turn run in parallel.
42
+ require "mistri"
84
43
 
85
- ```ruby
86
- weather = Mistri::Tool.define("get_weather", "Current weather for a city.", schema: lambda {
87
- string :city, "City name", required: true
88
- string :units, "Temperature units", enum: %w[celsius fahrenheit]
89
- }) do |args|
90
- Weather.for(args["city"], units: args["units"] || "celsius")
44
+ weather = Mistri::Tool.define(
45
+ "get_weather", "Current weather for a city.",
46
+ schema: -> { string :city, "City name", required: true },
47
+ ) do |args|
48
+ { city: args.fetch("city"), forecast: "34 C and clear" }
91
49
  end
92
- ```
93
50
 
94
- A tool can speak on two channels: `content` for the model, `ui` for your
95
- interface. The `ui` payload rides the `:tool_result` event and persists with
96
- the session, but never reaches a provider:
51
+ agent = Mistri.agent("claude-opus-4-8", tools: [weather])
97
52
 
98
- ```ruby
99
- Mistri::Tool.define("edit_page", "Applies a page edit.") do |args|
100
- page = apply(args)
101
- Mistri::ToolResult.new(content: "Saved.", ui: { "html" => page })
53
+ result = agent.run("What should I wear in Lahore today? One sentence.") do |event|
54
+ print event.delta if event.type == :text_delta
102
55
  end
56
+ puts
103
57
  ```
104
58
 
105
- Handlers and hooks can take the run's context as a second argument, and
106
- `context.app` carries whatever object you pass as `Mistri.agent(context:)`
107
- (the acting user, a tenant, a request), so tools stay authorization-aware
108
- without closure gymnastics:
59
+ `Mistri.agent` infers the provider from the model ID and reads
60
+ `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or `GEMINI_API_KEY`. Pass `api_key:`
61
+ explicitly when the host resolves credentials another way. A normally
62
+ completed invocation returns a `Mistri::Result` with status
63
+ predicates, final text, usage, and structured task output when requested.
64
+ Host-contract failures raise as documented in [Reliability](docs/reliability.md).
109
65
 
110
- ```ruby
111
- agent = Mistri.agent("claude-opus-4-8", tools: tools,
112
- context: { traveler: current_traveler })
66
+ Runnable examples live in [`examples/`](examples).
113
67
 
114
- Mistri::Tool.define("book_hotel", "Books the chosen hotel.") do |args, context|
115
- Bookings.create!(args, traveler: context.app[:traveler])
116
- end
117
- ```
68
+ ## Why Mistri
118
69
 
119
- A tool can also be the last word of its turn. `ends_turn: true` makes the
120
- loop end the run once the tool executes, instead of prompting the model
121
- again: an `ask_user` tool hands the floor to a human structurally, no
122
- "remember to stop after asking" prompt required. The result says it
123
- happened (`result.handed_off?`), and the answer arrives as the next run's
124
- input:
70
+ - **One session model.** The same append-only log runs over memory, JSONL,
71
+ Active Record, or a host store. With a persistent store, runs can suspend,
72
+ reload, resume, steer, and compact without inventing a second state model. See
73
+ [Sessions and control](docs/sessions.md).
74
+ - **Human approval does not hold a thread.** A gated tool parks the request and
75
+ returns. With a shared store, another process can record the decision later;
76
+ `resume` revalidates the exact call before execution.
77
+ - **Provider semantics survive the abstraction.** Anthropic, OpenAI, and
78
+ Gemini stream through direct implementations that retain each provider's
79
+ reasoning, tool-pairing, and replay requirements. Unknown model IDs still
80
+ pass through when their provider can be inferred.
81
+ - **Tool input crosses a real boundary.** Completed model arguments are owned,
82
+ bounded, validated, and deeply frozen before hooks, approval predicates, MCP,
83
+ or handlers see them. See [Tool contracts](docs/tool-contracts.md).
84
+ - **The runtime stays small.** The [gemspec](mistri.gemspec) declares no runtime
85
+ gem dependencies. Optional Rails adapters load only when required.
86
+ - **Real APIs are part of the test strategy.** The
87
+ [live integration suite](CONTRIBUTING.md#setup)
88
+ exercises critical control, persistence, delegation, MCP, and
89
+ structured-output paths across all three providers when their keys are
90
+ present. The default CI suite remains hermetic.
91
+
92
+ ## Choose the right layer
93
+
94
+ These projects solve different problems:
95
+
96
+ | Need | Start with |
97
+ | --- | --- |
98
+ | A durable tool loop inside any Ruby application, with append-only sessions, approval, steering, compaction, linked workers, and provider-neutral MCP | **Mistri** |
99
+ | Broad model access plus media, embeddings, image generation, and Rails-backed chat | [RubyLLM](https://github.com/crmne/ruby_llm) |
100
+ | Agents expressed through Rails controllers, actions, callbacks, views, and Active Job | [Active Agent](https://github.com/activeagents/activeagent) |
101
+
102
+ Mistri is deliberately not a terminal UI, hosted agent service, broad media
103
+ client, policy engine, or exactly-once job system. The host application keeps
104
+ its users, authorization rules, queues, database, UI, idempotency keys, and
105
+ reconciliation policy. Mistri supplies the execution mechanism that connects
106
+ them.
107
+
108
+ ## How the loop works
109
+
110
+ 1. The input is appended to the session before the provider is called.
111
+ 2. Provider events stream to the subscriber as the response arrives.
112
+ 3. Completed tool calls cross Mistri's ownership and validation boundary.
113
+ 4. Current host policy and approval rules run before a handler can start.
114
+ 5. Tool results append to the same session, then the model continues until it
115
+ answers, hands off the turn, suspends for approval, aborts, or reaches a
116
+ budget.
117
+
118
+ Every completed message is appended to the chosen Session store before the next
119
+ model-visible turn. Opaque reasoning and pairing metadata replay only to the
120
+ provider that created them; the shared transcript stays provider-neutral.
121
+
122
+ ## Tools and approval
123
+
124
+ A tool has a name, description, JSON Schema object, and handler. Independent
125
+ calls in one model turn execute concurrently up to `max_concurrency`.
125
126
 
126
127
  ```ruby
127
- ask_user = Mistri::Tool.define("ask_user", "Ask the human and wait.",
128
- ends_turn: true,
129
- schema: -> { string :question, "The question", required: true }) do |args|
130
- "Question presented to the user."
128
+ book_hotel = Mistri::Tool.define(
129
+ "book_hotel", "Books the selected hotel.",
130
+ schema: lambda {
131
+ string :hotel_id, "Hotel ID", required: true
132
+ number :total_usd, "Quoted total", required: true
133
+ },
134
+ needs_approval: ->(args) { args.fetch("total_usd") > 500 },
135
+ ) do |args, context|
136
+ Bookings.create!(
137
+ hotel_id: args.fetch("hotel_id"),
138
+ traveler: context.app.fetch(:traveler),
139
+ )
131
140
  end
141
+
142
+ agent = Mistri.agent(
143
+ "claude-opus-4-8",
144
+ tools: [book_hotel],
145
+ context: { traveler: current_traveler },
146
+ )
132
147
  ```
133
148
 
134
- ## Human approval
149
+ Validation runs before the approval predicate, so policy never receives a
150
+ string where the schema promised a number. Invalid calls return a bounded,
151
+ model-readable error and valid siblings still run. Mistri never coerces model
152
+ input implicitly.
135
153
 
136
- Mark a tool `needs_approval: true` (or a predicate on its arguments) and the
137
- run suspends instead of executing it, instantly, with no thread waiting.
138
- The decision is a one-line session write from any process, any time later;
139
- `resume` settles it and carries on.
154
+ When a gated call arrives:
140
155
 
141
156
  ```ruby
142
- book_hotel = Mistri::Tool.define("book_hotel", "Books the chosen hotel.",
143
- needs_approval: ->(args) { args["total_usd"].to_i > 500 }) do |args|
144
- Bookings.create!(args)
145
- end
146
-
147
157
  result = agent.run("Book the corner suite for the Lisbon trip")
148
- result.awaiting_approval? # => true; nothing executed
158
+ result.awaiting_approval? # => true; the handler did not run
149
159
 
150
- # Days later, in a controller:
151
- Mistri::Session.new(store:, id: session_id).approve(call_id) # or .deny(call_id, note: "...")
152
-
153
- # Then, in a worker:
154
- Mistri.agent("claude-opus-4-8", tools: tools, session: reloaded).resume
160
+ call = result.pending.fetch(0)
161
+ agent.session.approve(call.id, note: "Approved by finance")
162
+ result = agent.resume
155
163
  ```
156
164
 
157
- The harness renders nothing: it emits an `:approval_needed` event and your
158
- app draws the UI.
165
+ That is the smallest in-process flow. With a durable store, a controller,
166
+ route, mutation, job, or console can append the decision using only the Session
167
+ ID and call ID; a later process rebuilds current tools and policy before
168
+ resuming. See [Sessions and control](docs/sessions.md#human-approval).
159
169
 
160
- ## Steering
170
+ Tool results can also carry host-only UI data or an explicit failure fact. See
171
+ [Tool contracts](docs/tool-contracts.md) for schemas, validators, normalizers,
172
+ `Mistri::ToolResult`, hooks, and `ends_turn`.
161
173
 
162
- Queue a message into a running exchange from any process. It folds into the
163
- conversation at the next turn boundary; one that arrives as the model
164
- finishes cleanly extends the run so it gets answered.
174
+ ## Sessions and control
165
175
 
166
- ```ruby
167
- Mistri::Session.new(store:, id: session_id).steer("Make the headline blue instead.")
168
- ```
169
-
170
- Background workers' reports arrive through the same inbox (see Sub-agents).
171
- A host that wakes an idle session when a steer lands should watch
172
- `session.pending_inbox`, which holds both, in arrival order.
173
-
174
- ## Sessions
175
-
176
- A session is the durable record of a run: an append-only entry log over a
177
- pluggable store (memory, JSONL files, or your database).
176
+ The default memory store is useful for one process. JSONL persists the complete
177
+ append-only record on local disk without a database:
178
178
 
179
179
  ```ruby
180
- store = Mistri::Stores::JSONL.new("tmp/sessions")
181
- session = Mistri::Session.new(store:)
180
+ store = Mistri::Stores::JSONL.new("tmp/mistri-sessions")
181
+ session = Mistri::Session.new(store: store)
182
182
 
183
- agent = Mistri.agent("claude-opus-4-8", session:)
183
+ agent = Mistri.agent("claude-opus-4-8", session: session)
184
184
  agent.run("Start a haiku about the sea.")
185
185
 
186
- # Later, even in another process: reload by id and continue.
187
- resumed = Mistri.agent("claude-opus-4-8", session: Mistri::Session.new(store:, id: session.id))
188
- resumed.run("Now finish it.")
186
+ # A later process can reload the same session by ID.
187
+ reloaded = Mistri::Session.new(store: store, id: session.id)
188
+ Mistri.agent("claude-opus-4-8", session: reloaded).run("Now finish it.")
189
189
  ```
190
190
 
191
- In Rails, generate a model (name it whatever you like) and use the
192
- ActiveRecord store:
191
+ For a database-backed store, implement `append(id, entry)` and `load(id)`, or
192
+ use the optional Active Record adapter:
193
193
 
194
194
  ```console
195
195
  $ bin/rails generate mistri:install AgentEntry
@@ -197,317 +197,221 @@ $ bin/rails generate mistri:install AgentEntry
197
197
 
198
198
  ```ruby
199
199
  require "mistri/stores/active_record"
200
+
200
201
  store = Mistri::Stores::ActiveRecord.new(AgentEntry)
201
202
  ```
202
203
 
203
- ## Compaction
204
-
205
- Long sessions survive their context window: when the conversation grows into
206
- the reserve headroom, the provider writes a visible structured summary and
207
- replay continues from it. The full history stays in your store for
208
- transcript views. On by default whenever the model's window is known;
209
- `compaction: false` disables it.
204
+ Steering is another append, so a request handler or worker can redirect a live
205
+ exchange without sharing its Agent object:
210
206
 
211
207
  ```ruby
212
- agent.context_usage # => { tokens: 141_000, window: 200_000, fraction: 0.705 }
213
- agent.compact # the manual button
208
+ Mistri::Session.new(store: store, id: session_id).steer(
209
+ "Make the headline blue instead."
210
+ )
214
211
  ```
215
212
 
216
- `:compacting` and `:compaction` events carry the summary, so users see
217
- exactly what the model still remembers.
213
+ Only one Agent may actively call `run` or `resume` for a session at a time.
214
+ Approvals, steering, and worker reports may append concurrently. Read the
215
+ [reliability contract](docs/reliability.md) before connecting tools with
216
+ external side effects.
218
217
 
219
- ## Task mode
218
+ ## Streaming into any Ruby application
220
219
 
221
- A run that must end in JSON matching a schema. Tools run as usual; providers
222
- constrain the final answer natively where they can, and the answer is
223
- validated client-side everywhere. A violation goes back to the model once,
224
- then raises. You get a guaranteed shape or a loud error, never silence.
220
+ Every run accepts a block. Handle events directly, or compose a sink:
225
221
 
226
222
  ```ruby
227
- schema = {
228
- type: "object",
229
- properties: { "tiers" => { type: "array", items: { type: "string" } } },
230
- required: ["tiers"],
231
- }
223
+ sse = Mistri::Sinks::SSE.new(stream) # any IO-like object
224
+ sink = Mistri::Sinks::Coalesced.new(sse) # merge token bursts to UI speed
232
225
 
233
- result = agent.task("Extract the pricing tiers from this page.", schema: schema)
234
- result.output # => { "tiers" => [...] }, parsed and validated
226
+ agent.run(input, &sink)
235
227
  ```
236
228
 
237
- A run that suspends for approval, or that an `ends_turn` tool ended,
238
- returns as-is: validation applies to answers, not handoffs. Route on
239
- `result.handed_off?` and ask again once the human's answer arrives.
229
+ `Mistri::Sinks::ActionCable` is available for Rails, but no Railtie is required.
230
+ The same event stream works in Sinatra, Rack, a WebSocket server, a background
231
+ job, or a test. Event types form an extensible union; consumers should handle
232
+ the types they use and ignore the rest.
240
233
 
241
- ## Skills
234
+ ## Long conversations and structured tasks
242
235
 
243
- Expert playbooks with progressive disclosure: each skill costs one line in
244
- the system prompt until the model decides it is relevant and pulls the full
245
- body through an auto-provided `read_skill` tool.
236
+ Compaction is on by default for catalogued models. When a session enters the
237
+ model's safe headroom, Mistri asks the provider for a visible structured
238
+ summary, appends it, and continues from the summary plus a recent tail. The
239
+ exact history remains in the store for transcripts.
246
240
 
247
241
  ```ruby
248
- agent = Mistri.agent("claude-opus-4-8", skills: "app/skills") # or an array of Mistri::Skill
249
- ```
250
-
251
- A skill is a `SKILL.md` (or flat `.md`) with `name:`/`description:`
252
- frontmatter, or built from database rows with
253
- `Mistri::Skill.new(name:, description:, body:)`.
254
-
255
- ## Definitions
242
+ agent.context_usage
243
+ # => { tokens: 141_000, window: 1_000_000, fraction: 0.141 }
256
244
 
257
- An agent as a markdown file: YAML frontmatter for config, the body as the
258
- prompt, `{placeholders}` filled at build time (unfilled ones raise). Tool
259
- names and any extra keys stay your vocabulary; the gem only reads the
260
- file.
261
-
262
- ```ruby
263
- definition = Mistri::Definition.load("app/agents/trip_planner.md")
264
- agent = Mistri.agent(definition.model,
265
- system: definition.render(first_name: traveler.first_name),
266
- tools: registry.build(definition.tools, traveler))
245
+ agent.compact # explicit checkpoint; returns nil when nothing should compact
267
246
  ```
268
247
 
269
- ## Sub-agents
270
-
271
- Delegate to a child agent with a clean context: exploration fills the
272
- child's window, and only the final answer returns. Children run on their own
273
- sessions in your store, linked in the parent transcript; their events stream
274
- into the parent tagged with an `origin`. Each run of a specialist can carry
275
- its own name, so two parallel researchers read as Corgi and Beagle in your
276
- UI instead of "researcher" twice.
248
+ Task mode requires a final JSON value matching a schema. Providers use native
249
+ constraints only when they can represent the contract; Mistri validates the
250
+ answer locally in every case.
277
251
 
278
252
  ```ruby
279
- researcher = Mistri::SubAgent.new(
280
- name: "researcher", description: "Reads pages and answers factual questions.",
281
- provider: Mistri.provider("claude-haiku-4-5-20251001"), # cheaper model for grunt work
282
- system: "Research. Report findings only.", tools: [fetch_page],
283
- )
284
- agent = Mistri.agent("claude-opus-4-8", tools: [researcher.tool])
285
- ```
286
-
287
- Or hand the model an open spawn tool and let it compose its own workers:
288
- a name, instructions, a tool subset, and a host-allowlisted model per
289
- child. Several spawns in one turn fan out in parallel. `pack` is the whole
290
- kit: the spawn tool plus a management console (`list_agents`, `read_agent`,
291
- `steer_agent`, `stop_agent`), with curated types and an optional
292
- dispatcher:
293
-
294
- ```ruby
295
- spawn = Mistri::SubAgent.spawner(provider: provider, tools: [fetch_page, search])
296
-
297
- tools = Mistri::SubAgent.pack(
298
- provider: provider, tools: [fetch_page, search],
299
- types: { "researcher" => Mistri::Definition.load("agents/researcher.md") },
300
- models: ["claude-haiku-4-5-20251001"],
301
- dispatcher: Mistri::Dispatchers::Thread.new, # or one lambda onto your queue
302
- )
303
- ```
304
-
305
- A typed worker takes its prompt, tools, and model from the host's
306
- definition; `general-purpose` stays open for the model to compose.
307
- `max_children` (default 4) caps live workers, and every policy violation
308
- answers the model in band.
309
-
310
- Everything cross-process (stopping a worker from another process, the
311
- `:interrupted` liveness read, and the lease fence below) rides a lock
312
- adapter; configure one at boot. Without it, workers still run, but stops
313
- need the parent's own signal, a crashed child reads `:running`, and
314
- dispatched retries are unfenced:
253
+ schema = {
254
+ type: "object",
255
+ properties: {
256
+ "tiers" => { type: "array", items: { type: "string" } },
257
+ },
258
+ required: ["tiers"],
259
+ }
315
260
 
316
- ```ruby
317
- Mistri.locks = Mistri::Locks::RailsCache.new # Locks::Memory for a single process
261
+ result = agent.task("Extract the pricing tiers.", schema: schema)
262
+ result.output # parsed and validated
318
263
  ```
319
264
 
320
- With a dispatcher, `spawn_agent` takes `mode: "background"`: the model gets
321
- a truthful receipt at once and keeps working while the child runs. The
322
- console manages the roster with the same functions a host UI calls, so
323
- agent and user control stay structurally equal: either can read a worker's
324
- transcript, steer it mid-run, or stop it, from any process. When a worker
325
- finishes, its report delivers itself: a typed entry queues in the parent's
326
- inbox and folds at the next turn boundary as `[Corgi finished] <report>`
327
- (failures carry the error), while a `:subagent_report` event settles the
328
- worker's lane in whatever UI watched the spawn. In a queue host, the job
329
- rebuilds tools from the serializable spec and calls
330
- `SubAgent.run_dispatched`, which fences on the child's lease: a redelivered
331
- job leaves the running owner alone, a retry of a finished child is a no-op,
332
- and a retry of a crashed one runs it again.
333
-
334
- Everything about a child derives from the store and reads the same from
335
- any process, while it runs and forever after:
265
+ Task validation applies when that invocation completes normally. An approval
266
+ suspension or `ends_turn` handoff returns with `output` nil; after settlement or
267
+ the human answer, call `task` again if structured output is still required.
336
268
 
337
- ```ruby
338
- session.children # => [#<Mistri::Child Corgi running>, ...]
339
- child.status # :queued, :running, :done, :stopped, :failed
340
- child.report # the terminal entry's report, once finished
341
- child.say("Check pricing too") # folds at the child's next turn boundary
342
- child.stop # cooperative, cross-process, within a tick
343
-
344
- session.transcript(include_children: true)
345
- # the whole conversation, each child's log spliced at its link entry and
346
- # origin-tagged like the live stream: a reloading UI rebuilds its lanes
347
- ```
269
+ See [Sessions and control](docs/sessions.md) for compaction boundaries,
270
+ cross-provider replay, stores, approvals, and transcripts.
348
271
 
349
- ## Editing documents
272
+ ## Sub-agents
350
273
 
351
- The document tools (`read_file`, `edit_file`, `write_file`, `find_in_file`,
352
- `list_files`) work over a workspace: a directory, memory, ActiveRecord, or
353
- a single value anywhere, like one database column holding a page:
274
+ Delegate work into a child session so exploration does not consume the parent's
275
+ context. Only the report returns to the parent, while the full child transcript
276
+ remains linked and inspectable.
354
277
 
355
278
  ```ruby
356
- workspace = Mistri::Workspace::Single.new(
357
- read: -> { page.html },
358
- write: ->(html) { page.update!(html: html) },
359
- path: "hero.html",
279
+ researcher = Mistri::SubAgent.new(
280
+ name: "researcher",
281
+ description: "Reads pages and answers factual questions.",
282
+ provider: Mistri.provider("claude-haiku-4-5"),
283
+ system: "Research the question. Return findings and sources.",
284
+ tools: [fetch_page],
360
285
  )
361
- agent = Mistri.agent("claude-opus-4-8", tools: Mistri::Tools.files(workspace))
362
- ```
363
286
 
364
- The edit engine matches exactly, then whitespace-tolerantly; an ambiguous
365
- match refuses (never silently edits the wrong place), and a near-miss error
366
- names the closest region so the model's retry is one-shot.
367
-
368
- ## MCP
369
-
370
- Bridge any Model Context Protocol server's tools into an agent. The client
371
- speaks Streamable HTTP with zero new dependencies; auth is a token string
372
- or a lambda that re-resolves once on 401, so refresh logic lives in one
373
- place. Approval gates compose: a third-party write tool can require a
374
- human.
375
-
376
- ```ruby
377
- client = Mistri::MCP::Client.new(url: "https://mcp.linear.app/mcp",
378
- token: -> { connection.bearer_token })
379
- tools = Mistri::MCP.tools(client, prefix: "linear",
380
- gates: { "create_issue" => true })
381
-
382
- agent = Mistri.agent("claude-opus-4-8", tools: tools)
383
- ```
384
-
385
- The bridge lists the server's tools once, at build time; `client.tools(refresh: true)`
386
- re-lists when a host wants a changed toolset. `prefix:` namespaces local
387
- names (`linear__create_issue`) because duplicate tool names raise at
388
- `Agent.new`: collisions fail loud instead of one server's tool silently
389
- shadowing another's.
390
-
391
- Local stdio servers spawn as child processes, credentials in their
392
- environment. That is also the whole "give the agent a browser" story:
393
-
394
- ```ruby
395
- browser = Mistri::MCP::Client.new(
396
- command: ["npx", "-y", "@playwright/mcp@latest", "--browser", "chrome", "--headless"],
397
- )
398
- agent = Mistri.agent("claude-opus-4-8",
399
- tools: Mistri::MCP.tools(browser, allow: %w[browser_navigate browser_snapshot]))
287
+ agent = Mistri.agent("claude-opus-4-8", tools: [researcher.tool])
400
288
  ```
401
289
 
402
- For the full connect-your-tools story in Rails, generate a connection model
403
- (name it whatever you like):
290
+ Mistri also provides a host-bounded spawn tool, named worker types, background
291
+ dispatch with worker-side runtime reconstruction, exact capability grants,
292
+ reports, steering, stopping, and a management console. See
293
+ [Sub-agents](docs/sub-agents.md), including the runtime, concurrency, and lease
294
+ limits a production dispatcher must understand.
404
295
 
405
- ```console
406
- $ bin/rails generate mistri:mcp McpConnection
407
- ```
296
+ ## Model Context Protocol
408
297
 
409
- Each row is one server connection carrying its own OAuth flow state and
410
- encrypted tokens. The OAuth services underneath (`Mistri::MCP::OAuth.start`,
411
- `.complete`, `.refresh`) are storage-agnostic, so the same flow works from a
412
- controller, a GraphQL mutation, or a job. Registration happens as your
413
- application: `client_name:` is yours to set.
298
+ Bridge tools over Streamable HTTP or stdio, then apply the same local approval
299
+ and validation boundary:
414
300
 
415
301
  ```ruby
416
- connection, authorize_url = McpConnection.connect(
417
- name: "Linear", url: params[:url],
418
- client_name: "YourApp", redirect_uri: mcp_callback_url,
302
+ client = Mistri::MCP::Client.new(
303
+ url: "https://mcp.linear.app/mcp",
304
+ token: -> { connection.bearer_token },
419
305
  )
420
- # redirect the user to authorize_url; then, in the callback:
421
- connection = McpConnection.complete(state: params[:state], code: params[:code])
422
-
423
- agent = Mistri.agent("claude-opus-4-8", tools: connection.tools(prefix: "linear"))
424
- ```
425
-
426
- ## Streaming into Rails
427
306
 
428
- Sinks bridge the event stream to a transport, and compose as blocks:
429
-
430
- ```ruby
431
- cable = Mistri::Sinks::ActionCable.new("agent_#{session.id}")
432
- sink = Mistri::Sinks::Coalesced.new(cable) # merges token bursts to UI speed
433
-
434
- agent.run(input, &sink)
435
- ```
436
-
437
- `Mistri::Sinks::SSE.new(response.stream)` does the same for
438
- `ActionController::Live`. There is no Railtie and nothing to configure;
439
- the generator and stores duck-type into any app.
440
-
441
- ## Stopping, budgets, reliability
442
-
443
- ```ruby
444
- # Trip the signal from anywhere; the partial turn persists, resume is clean.
445
- signal = Mistri::AbortSignal.new
446
- agent.run("Draft a long essay.", signal: signal)
447
-
448
- # Ceilings are opt-in and off by default.
449
- budget = Mistri::Budget.new(turns: 20, cost_usd: 2.00)
450
-
451
- # Transient failures (429, 5xx, timeouts) retry with backoff, invisibly to
452
- # the model. On by default; retries: false disables.
453
- policy = Mistri::RetryPolicy.new(attempts: 3)
454
- ```
455
-
456
- Retries are invisible to the model but not to your UI: each backoff emits a
457
- `:retry` event carrying `attempt`, `max_attempts`, and `delay`, so a sink can
458
- show a live "reconnecting" state instead of a silent spinner. Terminal events
459
- are loop-owned: `:done` and `:error` reach the subscriber only for the
460
- accepted attempt, so a recovered retry never flashes an error it then walks
461
- back.
462
-
463
- ```ruby
464
- agent.run("Plan the itinerary.") do |event|
465
- case event.type
466
- when :text_delta then stream(event.delta)
467
- when :retry then banner("Retrying (#{event.attempt}/#{event.max_attempts}) in #{event.delay}s")
468
- when :done, :error then clear_banner
469
- end
470
- end
307
+ tools = Mistri::MCP.tools(
308
+ client,
309
+ prefix: "linear",
310
+ gates: { "create_issue" => true },
311
+ )
471
312
  ```
472
313
 
473
- ## Images and provider options
474
-
475
- ```ruby
476
- photo = Mistri::Content::Image.from_bytes(File.binread("chart.png"), mime_type: "image/png")
477
- photo = Mistri::Content::Image.from_data_uri(params[:image]) # canvases and uploads
478
- agent.run("What trend does this chart show?", images: [photo])
314
+ Remote URLs default to public HTTPS with validated, pinned DNS answers. Inbound
315
+ records are size-bounded. An unconfirmed `tools/call` is never replayed
316
+ automatically because the server may already have committed its side effect.
317
+ OAuth services are storage-agnostic; the Rails connection generator is one
318
+ optional host adapter.
319
+
320
+ Read [MCP](docs/mcp.md) for stdio, OAuth, private-network policy, schema
321
+ authority, response limits, and large-result resource links.
322
+
323
+ ## Skills, definitions, and workspaces
324
+
325
+ - **Skills** expose one-line descriptions until the model requests the full
326
+ `SKILL.md`, keeping the system prompt small.
327
+ - **Definitions** load an agent's model, tool names, and prompt from Markdown
328
+ with YAML frontmatter and checked placeholders.
329
+ - **Workspaces** give the built-in read, write, edit, find, and list tools a
330
+ directory, memory value, Active Record table, or single host-owned value.
331
+
332
+ The edit engine refuses ambiguous matches and, when a workspace supports
333
+ conditional writes, rebases anchored edits without losing unrelated concurrent
334
+ changes. A Directory workspace rejects lexical escape and traversal through
335
+ existing symlinks in a stable, host-controlled tree; it is not an
336
+ operating-system sandbox. See
337
+ [Context and workspaces](docs/context-and-workspaces.md) and the runnable
338
+ [`page_editor.rb`](examples/page_editor.rb) example.
339
+
340
+ ## Reliability contract
341
+
342
+ Mistri makes failures explicit, but it does not pretend distributed side
343
+ effects are exactly once:
344
+
345
+ - Provider requests retry transient failures with backoff. The MCP client never
346
+ replays an ambiguous tool call; the Agent bridge returns the uncertainty as
347
+ an error-marked tool result.
348
+ - A process can crash after a handler commits an external write but before the
349
+ result reaches the session. Tools with side effects must be idempotent or
350
+ reconcilable.
351
+ - Budgets are checked between model-visible turns, so they are soft ceilings.
352
+ - Provider and MCP records default to an 8 MiB per-record limit; streaming has
353
+ no total-size limit while each record remains safe.
354
+ - `Mistri::AbortSignal` is an in-process cooperative signal. Cross-process
355
+ worker control uses the configured lock adapter and is also cooperative.
356
+
357
+ The complete operational contract, event lifecycle, retry behavior, pricing
358
+ rules, and sink guidance live in [Reliability](docs/reliability.md).
359
+
360
+ ## Documentation
361
+
362
+ All primary documentation is versioned with the code and renders directly on
363
+ GitHub:
364
+
365
+ | Guide | What it covers |
366
+ | --- | --- |
367
+ | [Documentation index](docs/README.md) | The complete task-oriented map |
368
+ | [Tool contracts](docs/tool-contracts.md) | Schemas, validation, approval, hooks, results, and handoff |
369
+ | [Sessions and control](docs/sessions.md) | Stores, replay, approvals, steering, compaction, and transcripts |
370
+ | [Context and workspaces](docs/context-and-workspaces.md) | Skills, definitions, context transforms, host-owned memory, and editable documents |
371
+ | [Sub-agents](docs/sub-agents.md) | Specialists, spawning, dispatch, locks, reports, and control |
372
+ | [MCP](docs/mcp.md) | HTTP and stdio, OAuth, egress policy, limits, and schema handling |
373
+ | [Reliability](docs/reliability.md) | Retries, events, budgets, stopping, concurrency, and side effects |
374
+ | [Upgrade guide](UPGRADING.md) | Required host changes between releases |
375
+ | [Changelog](CHANGELOG.md) | Complete release history and exact behavior changes |
376
+
377
+ ## Testing and contributing
378
+
379
+ The default suite is hermetic and needs no API keys:
479
380
 
480
- Mistri.agent("gpt-5.5", provider_options: { reasoning: { effort: "high" } })
481
- Mistri.agent("claude-opus-4-8", provider_options: { cache: false })
381
+ ```console
382
+ $ bundle exec rake test
383
+ $ bundle exec rubocop
482
384
  ```
483
385
 
484
- ## Testing
485
-
486
- `rake test` is hermetic and fast. The Fake provider streams like the real
487
- ones, tool-call arguments included: each delta's partial carries the
488
- in-progress call with arguments parsed so far, so a UI that renders tool
489
- input as it arrives tests headless. `rake integration` runs every feature
490
- end to end against real provider APIs, once per model in the matrix: an
491
- Anthropic, an OpenAI, and a Gemini model by default. Scenarios assert that
492
- coined codenames (a ghost of a word like `Wraithowyn` exists in no training
493
- data) flowed through tool results, summaries, and child agents: proof of
494
- information flow, not model knowledge.
386
+ Live tests read provider keys from the gitignored
387
+ `.env.development.local` file:
495
388
 
496
389
  ```console
390
+ $ MISTRI_LIVE=1 bundle exec rake test
497
391
  $ bundle exec rake integration
498
392
  $ MISTRI_INTEGRATION_MODELS=claude-opus-4-8 bundle exec rake integration
499
393
  ```
500
394
 
501
- ## Roadmap
395
+ The integration matrix exercises critical end-to-end scenarios against one
396
+ Anthropic, OpenAI, and Gemini model by default when the corresponding keys are
397
+ present. See
398
+ [CONTRIBUTING.md](CONTRIBUTING.md) for the project contract.
399
+
400
+ ## Compatibility and support
401
+
402
+ Mistri supports Ruby 3.2 and newer. It is pre-1.0: minor releases may include
403
+ intentional contract changes with an explicit migration path. Read
404
+ [UPGRADING.md](UPGRADING.md) and [CHANGELOG.md](CHANGELOG.md) before upgrading.
502
405
 
503
- Next up: strict tool schemas, provider-native MCP passthrough, and the
504
- hardening that falls out of the first production applications.
406
+ Use [GitHub issues](https://github.com/mcheemaa/mistri/issues) for reproducible
407
+ bugs and focused feature proposals. Report vulnerabilities privately as
408
+ described in [SECURITY.md](SECURITY.md).
505
409
 
506
410
  ## Credits
507
411
 
508
412
  Mistri's architecture is informed by [pi](https://github.com/badlogic/pi-mono)
509
- by Mario Zechner. See NOTICE.
413
+ by Mario Zechner. See [NOTICE](NOTICE).
510
414
 
511
415
  ## License
512
416
 
513
- MIT. See LICENSE.
417
+ MIT. See [LICENSE](LICENSE).