mistri 0.4.1 → 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 (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +596 -3
  3. data/CONTRIBUTING.md +52 -0
  4. data/README.md +291 -306
  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/abort_signal.rb +10 -0
  28. data/lib/mistri/agent.rb +635 -108
  29. data/lib/mistri/budget.rb +26 -1
  30. data/lib/mistri/child.rb +186 -0
  31. data/lib/mistri/compaction.rb +26 -10
  32. data/lib/mistri/compactor.rb +35 -12
  33. data/lib/mistri/console.rb +209 -0
  34. data/lib/mistri/content.rb +9 -3
  35. data/lib/mistri/dispatchers.rb +49 -0
  36. data/lib/mistri/errors.rb +83 -4
  37. data/lib/mistri/event.rb +30 -8
  38. data/lib/mistri/event_delivery.rb +60 -0
  39. data/lib/mistri/locks/rails_cache.rb +48 -0
  40. data/lib/mistri/locks.rb +141 -0
  41. data/lib/mistri/mcp/client.rb +74 -19
  42. data/lib/mistri/mcp/egress.rb +216 -0
  43. data/lib/mistri/mcp/oauth.rb +476 -127
  44. data/lib/mistri/mcp/wires.rb +115 -23
  45. data/lib/mistri/mcp.rb +43 -9
  46. data/lib/mistri/message.rb +21 -11
  47. data/lib/mistri/models.rb +160 -22
  48. data/lib/mistri/providers/anthropic/assembler.rb +282 -44
  49. data/lib/mistri/providers/anthropic/serializer.rb +14 -9
  50. data/lib/mistri/providers/anthropic.rb +29 -6
  51. data/lib/mistri/providers/fake.rb +36 -6
  52. data/lib/mistri/providers/gemini/assembler.rb +148 -21
  53. data/lib/mistri/providers/gemini/serializer.rb +78 -9
  54. data/lib/mistri/providers/gemini.rb +31 -5
  55. data/lib/mistri/providers/openai/assembler.rb +337 -60
  56. data/lib/mistri/providers/openai/serializer.rb +13 -12
  57. data/lib/mistri/providers/openai.rb +29 -5
  58. data/lib/mistri/providers/schema_capabilities.rb +214 -0
  59. data/lib/mistri/result.rb +8 -3
  60. data/lib/mistri/retry_policy.rb +2 -2
  61. data/lib/mistri/schema.rb +893 -75
  62. data/lib/mistri/session.rb +649 -47
  63. data/lib/mistri/sinks/coalesced.rb +17 -10
  64. data/lib/mistri/skill.rb +1 -1
  65. data/lib/mistri/skills.rb +1 -1
  66. data/lib/mistri/spawner.rb +316 -0
  67. data/lib/mistri/sse.rb +57 -14
  68. data/lib/mistri/stores/active_record.rb +22 -7
  69. data/lib/mistri/stores/jsonl.rb +3 -1
  70. data/lib/mistri/stores/memory.rb +21 -2
  71. data/lib/mistri/sub_agent/execution.rb +81 -0
  72. data/lib/mistri/sub_agent/runtime.rb +297 -0
  73. data/lib/mistri/sub_agent.rb +238 -103
  74. data/lib/mistri/task_output.rb +58 -0
  75. data/lib/mistri/tool.rb +102 -13
  76. data/lib/mistri/tool_arguments.rb +377 -0
  77. data/lib/mistri/tool_call.rb +43 -9
  78. data/lib/mistri/tool_context.rb +7 -5
  79. data/lib/mistri/tool_executor.rb +117 -26
  80. data/lib/mistri/tool_result.rb +15 -10
  81. data/lib/mistri/tools/edit_file.rb +62 -8
  82. data/lib/mistri/tools.rb +41 -4
  83. data/lib/mistri/transport.rb +149 -44
  84. data/lib/mistri/usage.rb +65 -13
  85. data/lib/mistri/version.rb +1 -1
  86. data/lib/mistri/workspace/active_record.rb +183 -3
  87. data/lib/mistri/workspace/directory.rb +28 -8
  88. data/lib/mistri/workspace/memory.rb +34 -9
  89. data/lib/mistri/workspace/single.rb +62 -5
  90. data/lib/mistri/workspace.rb +39 -0
  91. data/lib/mistri.rb +17 -1
  92. data/mistri.gemspec +34 -0
  93. metadata +38 -3
data/README.md CHANGED
@@ -7,22 +7,36 @@
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
+
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.
30
+
31
+ ## Start in sixty seconds
32
+
33
+ Add Mistri to your bundle:
34
+
35
+ ```ruby
36
+ gem "mistri"
37
+ ```
38
+
39
+ Then define a tool and run an agent:
26
40
 
27
41
  ```ruby
28
42
  require "mistri"
@@ -31,146 +45,151 @@ weather = Mistri::Tool.define(
31
45
  "get_weather", "Current weather for a city.",
32
46
  schema: -> { string :city, "City name", required: true },
33
47
  ) do |args|
34
- Weather.for(args["city"])
48
+ { city: args.fetch("city"), forecast: "34 C and clear" }
35
49
  end
36
50
 
37
- agent = Mistri.agent("claude-opus-4-8", tools: [weather]) # reads ANTHROPIC_API_KEY
51
+ agent = Mistri.agent("claude-opus-4-8", tools: [weather])
38
52
 
39
- agent.run("What should I wear in Lahore today?") do |event|
53
+ result = agent.run("What should I wear in Lahore today? One sentence.") do |event|
40
54
  print event.delta if event.type == :text_delta
41
55
  end
56
+ puts
42
57
  ```
43
58
 
44
- ## Why Mistri
45
-
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`).
57
-
58
- ## Install
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).
59
65
 
60
- ```ruby
61
- gem "mistri"
62
- ```
66
+ Runnable examples live in [`examples/`](examples).
63
67
 
64
- ## Sixty-second start
65
-
66
- ```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.
84
-
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")
91
- end
92
- ```
68
+ ## Why Mistri
93
69
 
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:
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`.
97
126
 
98
127
  ```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 })
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
+ )
102
140
  end
103
- ```
104
-
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:
109
141
 
110
- ```ruby
111
- agent = Mistri.agent("claude-opus-4-8", tools: tools,
112
- context: { traveler: current_traveler })
113
-
114
- Mistri::Tool.define("book_hotel", "Books the chosen hotel.") do |args, context|
115
- Bookings.create!(args, traveler: context.app[:traveler])
116
- end
142
+ agent = Mistri.agent(
143
+ "claude-opus-4-8",
144
+ tools: [book_hotel],
145
+ context: { traveler: current_traveler },
146
+ )
117
147
  ```
118
148
 
119
- ## 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.
120
153
 
121
- Mark a tool `needs_approval: true` (or a predicate on its arguments) and the
122
- run suspends instead of executing it, instantly, with no thread waiting.
123
- The decision is a one-line session write from any process, any time later;
124
- `resume` settles it and carries on.
154
+ When a gated call arrives:
125
155
 
126
156
  ```ruby
127
- book_hotel = Mistri::Tool.define("book_hotel", "Books the chosen hotel.",
128
- needs_approval: ->(args) { args["total_usd"].to_i > 500 }) do |args|
129
- Bookings.create!(args)
130
- end
131
-
132
157
  result = agent.run("Book the corner suite for the Lisbon trip")
133
- result.awaiting_approval? # => true; nothing executed
134
-
135
- # Days later, in a controller:
136
- Mistri::Session.new(store:, id: session_id).approve(call_id) # or .deny(call_id, note: "...")
158
+ result.awaiting_approval? # => true; the handler did not run
137
159
 
138
- # Then, in a worker:
139
- 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
140
163
  ```
141
164
 
142
- The harness renders nothing: it emits an `:approval_needed` event and your
143
- 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).
144
169
 
145
- ## 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`.
146
173
 
147
- Queue a message into a running exchange from any process. It folds into the
148
- conversation at the next turn boundary; one that arrives as the model
149
- finishes cleanly extends the run so it gets answered.
174
+ ## Sessions and control
150
175
 
151
- ```ruby
152
- Mistri::Session.new(store:, id: session_id).steer("Make the headline blue instead.")
153
- ```
154
-
155
- ## Sessions
156
-
157
- A session is the durable record of a run: an append-only entry log over a
158
- 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:
159
178
 
160
179
  ```ruby
161
- store = Mistri::Stores::JSONL.new("tmp/sessions")
162
- session = Mistri::Session.new(store:)
180
+ store = Mistri::Stores::JSONL.new("tmp/mistri-sessions")
181
+ session = Mistri::Session.new(store: store)
163
182
 
164
- agent = Mistri.agent("claude-opus-4-8", session:)
183
+ agent = Mistri.agent("claude-opus-4-8", session: session)
165
184
  agent.run("Start a haiku about the sea.")
166
185
 
167
- # Later, even in another process: reload by id and continue.
168
- resumed = Mistri.agent("claude-opus-4-8", session: Mistri::Session.new(store:, id: session.id))
169
- 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.")
170
189
  ```
171
190
 
172
- In Rails, generate a model (name it whatever you like) and use the
173
- ActiveRecord store:
191
+ For a database-backed store, implement `append(id, entry)` and `load(id)`, or
192
+ use the optional Active Record adapter:
174
193
 
175
194
  ```console
176
195
  $ bin/rails generate mistri:install AgentEntry
@@ -178,255 +197,221 @@ $ bin/rails generate mistri:install AgentEntry
178
197
 
179
198
  ```ruby
180
199
  require "mistri/stores/active_record"
200
+
181
201
  store = Mistri::Stores::ActiveRecord.new(AgentEntry)
182
202
  ```
183
203
 
184
- ## Compaction
185
-
186
- Long sessions survive their context window: when the conversation grows into
187
- the reserve headroom, the provider writes a visible structured summary and
188
- replay continues from it. The full history stays in your store for
189
- transcript views. On by default whenever the model's window is known;
190
- `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:
191
206
 
192
207
  ```ruby
193
- agent.context_usage # => { tokens: 141_000, window: 200_000, fraction: 0.705 }
194
- agent.compact # the manual button
208
+ Mistri::Session.new(store: store, id: session_id).steer(
209
+ "Make the headline blue instead."
210
+ )
195
211
  ```
196
212
 
197
- `:compacting` and `:compaction` events carry the summary, so users see
198
- 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.
199
217
 
200
- ## Task mode
218
+ ## Streaming into any Ruby application
201
219
 
202
- A run that must end in JSON matching a schema. Tools run as usual; providers
203
- constrain the final answer natively where they can, and the answer is
204
- validated client-side everywhere. A violation goes back to the model once,
205
- 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:
206
221
 
207
222
  ```ruby
208
- schema = {
209
- type: "object",
210
- properties: { "tiers" => { type: "array", items: { type: "string" } } },
211
- required: ["tiers"],
212
- }
213
-
214
- result = agent.task("Extract the pricing tiers from this page.", schema: schema)
215
- result.output # => { "tiers" => [...] }, parsed and validated
216
- ```
217
-
218
- ## Skills
219
-
220
- Expert playbooks with progressive disclosure: each skill costs one line in
221
- the system prompt until the model decides it is relevant and pulls the full
222
- body through an auto-provided `read_skill` tool.
223
-
224
- ```ruby
225
- agent = Mistri.agent("claude-opus-4-8", skills: "app/skills") # or an array of Mistri::Skill
226
- ```
227
-
228
- A skill is a `SKILL.md` (or flat `.md`) with `name:`/`description:`
229
- frontmatter, or built from database rows with
230
- `Mistri::Skill.new(name:, description:, body:)`.
223
+ sse = Mistri::Sinks::SSE.new(stream) # any IO-like object
224
+ sink = Mistri::Sinks::Coalesced.new(sse) # merge token bursts to UI speed
231
225
 
232
- ## Definitions
233
-
234
- An agent as a markdown file: YAML frontmatter for config, the body as the
235
- prompt, `{placeholders}` filled at build time (unfilled ones raise). Tool
236
- names and any extra keys stay your vocabulary; the gem only reads the
237
- file.
238
-
239
- ```ruby
240
- definition = Mistri::Definition.load("app/agents/trip_planner.md")
241
- agent = Mistri.agent(definition.model,
242
- system: definition.render(first_name: traveler.first_name),
243
- tools: registry.build(definition.tools, traveler))
226
+ agent.run(input, &sink)
244
227
  ```
245
228
 
246
- ## Sub-agents
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.
247
233
 
248
- Delegate to a child agent with a clean context: exploration fills the
249
- child's window, and only the final answer returns. Children run on their own
250
- sessions in your store, linked in the parent transcript; their events stream
251
- into the parent tagged with an `origin`.
234
+ ## Long conversations and structured tasks
252
235
 
253
- ```ruby
254
- researcher = Mistri::SubAgent.new(
255
- name: "researcher", description: "Reads pages and answers factual questions.",
256
- provider: Mistri.provider("claude-haiku-4-5-20251001"), # cheaper model for grunt work
257
- system: "Research. Report findings only.", tools: [fetch_page],
258
- )
259
- agent = Mistri.agent("claude-opus-4-8", tools: [researcher.tool])
260
- ```
261
-
262
- Or hand the model an open spawn tool and let it compose its own workers:
263
- a name for the event stream, instructions, a tool subset, and a
264
- host-allowlisted model per child. Several spawns in one turn fan out in
265
- parallel:
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.
266
240
 
267
241
  ```ruby
268
- spawn = Mistri::SubAgent.spawner(provider: provider, tools: [fetch_page, search])
269
- ```
270
-
271
- ## Editing documents
242
+ agent.context_usage
243
+ # => { tokens: 141_000, window: 1_000_000, fraction: 0.141 }
272
244
 
273
- The document tools (`read_file`, `edit_file`, `write_file`, `find_in_file`,
274
- `list_files`) work over a workspace: a directory, memory, ActiveRecord, or
275
- a single value anywhere, like one database column holding a page:
276
-
277
- ```ruby
278
- workspace = Mistri::Workspace::Single.new(
279
- read: -> { page.html },
280
- write: ->(html) { page.update!(html: html) },
281
- path: "hero.html",
282
- )
283
- agent = Mistri.agent("claude-opus-4-8", tools: Mistri::Tools.files(workspace))
245
+ agent.compact # explicit checkpoint; returns nil when nothing should compact
284
246
  ```
285
247
 
286
- The edit engine matches exactly, then whitespace-tolerantly; an ambiguous
287
- match refuses (never silently edits the wrong place), and a near-miss error
288
- names the closest region so the model's retry is one-shot.
289
-
290
- ## MCP
291
-
292
- Bridge any Model Context Protocol server's tools into an agent. The client
293
- speaks Streamable HTTP with zero new dependencies; auth is a token string
294
- or a lambda that re-resolves once on 401, so refresh logic lives in one
295
- place. Approval gates compose: a third-party write tool can require a
296
- human.
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.
297
251
 
298
252
  ```ruby
299
- client = Mistri::MCP::Client.new(url: "https://mcp.linear.app/mcp",
300
- token: -> { connection.bearer_token })
301
- tools = Mistri::MCP.tools(client, prefix: "linear",
302
- gates: { "create_issue" => true })
253
+ schema = {
254
+ type: "object",
255
+ properties: {
256
+ "tiers" => { type: "array", items: { type: "string" } },
257
+ },
258
+ required: ["tiers"],
259
+ }
303
260
 
304
- agent = Mistri.agent("claude-opus-4-8", tools: tools)
261
+ result = agent.task("Extract the pricing tiers.", schema: schema)
262
+ result.output # parsed and validated
305
263
  ```
306
264
 
307
- The bridge lists the server's tools once, at build time; `client.tools(refresh: true)`
308
- re-lists when a host wants a changed toolset. `prefix:` namespaces local
309
- names (`linear__create_issue`) because duplicate tool names raise at
310
- `Agent.new`: collisions fail loud instead of one server's tool silently
311
- shadowing another's.
312
-
313
- Local stdio servers spawn as child processes, credentials in their
314
- environment. That is also the whole "give the agent a browser" story:
315
-
316
- ```ruby
317
- browser = Mistri::MCP::Client.new(
318
- command: ["npx", "-y", "@playwright/mcp@latest", "--browser", "chrome", "--headless"],
319
- )
320
- agent = Mistri.agent("claude-opus-4-8",
321
- tools: Mistri::MCP.tools(browser, allow: %w[browser_navigate browser_snapshot]))
322
- ```
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.
323
268
 
324
- For the full connect-your-tools story in Rails, generate a connection model
325
- (name it whatever you like):
269
+ See [Sessions and control](docs/sessions.md) for compaction boundaries,
270
+ cross-provider replay, stores, approvals, and transcripts.
326
271
 
327
- ```console
328
- $ bin/rails generate mistri:mcp McpConnection
329
- ```
272
+ ## Sub-agents
330
273
 
331
- Each row is one server connection carrying its own OAuth flow state and
332
- encrypted tokens. The OAuth services underneath (`Mistri::MCP::OAuth.start`,
333
- `.complete`, `.refresh`) are storage-agnostic, so the same flow works from a
334
- controller, a GraphQL mutation, or a job. Registration happens as your
335
- application: `client_name:` is yours to set.
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.
336
277
 
337
278
  ```ruby
338
- connection, authorize_url = McpConnection.connect(
339
- name: "Linear", url: params[:url],
340
- client_name: "YourApp", redirect_uri: mcp_callback_url,
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],
341
285
  )
342
- # redirect the user to authorize_url; then, in the callback:
343
- connection = McpConnection.complete(state: params[:state], code: params[:code])
344
286
 
345
- agent = Mistri.agent("claude-opus-4-8", tools: connection.tools(prefix: "linear"))
287
+ agent = Mistri.agent("claude-opus-4-8", tools: [researcher.tool])
346
288
  ```
347
289
 
348
- ## Streaming into Rails
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.
349
295
 
350
- Sinks bridge the event stream to a transport, and compose as blocks:
296
+ ## Model Context Protocol
351
297
 
352
- ```ruby
353
- cable = Mistri::Sinks::ActionCable.new("agent_#{session.id}")
354
- sink = Mistri::Sinks::Coalesced.new(cable) # merges token bursts to UI speed
355
-
356
- agent.run(input, &sink)
357
- ```
358
-
359
- `Mistri::Sinks::SSE.new(response.stream)` does the same for
360
- `ActionController::Live`. There is no Railtie and nothing to configure;
361
- the generator and stores duck-type into any app.
362
-
363
- ## Stopping, budgets, reliability
298
+ Bridge tools over Streamable HTTP or stdio, then apply the same local approval
299
+ and validation boundary:
364
300
 
365
301
  ```ruby
366
- # Trip the signal from anywhere; the partial turn persists, resume is clean.
367
- signal = Mistri::AbortSignal.new
368
- agent.run("Draft a long essay.", signal: signal)
369
-
370
- # Ceilings are opt-in and off by default.
371
- budget = Mistri::Budget.new(turns: 20, cost_usd: 2.00)
372
-
373
- # Transient failures (429, 5xx, timeouts) retry with backoff, invisibly to
374
- # the model. On by default; retries: false disables.
375
- policy = Mistri::RetryPolicy.new(attempts: 3)
376
- ```
377
-
378
- Retries are invisible to the model but not to your UI: each backoff emits a
379
- `:retry` event carrying `attempt`, `max_attempts`, and `delay`, so a sink can
380
- show a live "reconnecting" state instead of a silent spinner. Terminal events
381
- are loop-owned: `:done` and `:error` reach the subscriber only for the
382
- accepted attempt, so a recovered retry never flashes an error it then walks
383
- back.
302
+ client = Mistri::MCP::Client.new(
303
+ url: "https://mcp.linear.app/mcp",
304
+ token: -> { connection.bearer_token },
305
+ )
384
306
 
385
- ```ruby
386
- agent.run("Plan the itinerary.") do |event|
387
- case event.type
388
- when :text_delta then stream(event.delta)
389
- when :retry then banner("Retrying (#{event.attempt}/#{event.max_attempts}) in #{event.delay}s")
390
- when :done, :error then clear_banner
391
- end
392
- end
307
+ tools = Mistri::MCP.tools(
308
+ client,
309
+ prefix: "linear",
310
+ gates: { "create_issue" => true },
311
+ )
393
312
  ```
394
313
 
395
- ## Images and provider options
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:
396
380
 
397
- ```ruby
398
- photo = Mistri::Content::Image.from_bytes(File.binread("chart.png"), mime_type: "image/png")
399
- photo = Mistri::Content::Image.from_data_uri(params[:image]) # canvases and uploads
400
- agent.run("What trend does this chart show?", images: [photo])
401
-
402
- Mistri.agent("gpt-5.5", provider_options: { reasoning: { effort: "high" } })
403
- Mistri.agent("claude-opus-4-8", provider_options: { cache: false })
381
+ ```console
382
+ $ bundle exec rake test
383
+ $ bundle exec rubocop
404
384
  ```
405
385
 
406
- ## Testing
407
-
408
- `rake test` is hermetic and fast. `rake integration` runs every feature end
409
- to end against real provider APIs, once per model in the matrix: an
410
- Anthropic, an OpenAI, and a Gemini model by default. Scenarios assert that
411
- coined codenames (a ghost of a word like `Wraithowyn` exists in no training
412
- data) flowed through tool results, summaries, and child agents: proof of
413
- information flow, not model knowledge.
386
+ Live tests read provider keys from the gitignored
387
+ `.env.development.local` file:
414
388
 
415
389
  ```console
390
+ $ MISTRI_LIVE=1 bundle exec rake test
416
391
  $ bundle exec rake integration
417
392
  $ MISTRI_INTEGRATION_MODELS=claude-opus-4-8 bundle exec rake integration
418
393
  ```
419
394
 
420
- ## 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.
421
405
 
422
- Next up: strict tool schemas, provider-native MCP passthrough, and the
423
- 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).
424
409
 
425
410
  ## Credits
426
411
 
427
412
  Mistri's architecture is informed by [pi](https://github.com/badlogic/pi-mono)
428
- by Mario Zechner. See NOTICE.
413
+ by Mario Zechner. See [NOTICE](NOTICE).
429
414
 
430
415
  ## License
431
416
 
432
- MIT. See LICENSE.
417
+ MIT. See [LICENSE](LICENSE).