mistri 0.0.3 → 0.2.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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +215 -0
  3. data/README.md +367 -3
  4. data/lib/generators/mistri/install/install_generator.rb +54 -0
  5. data/lib/generators/mistri/install/templates/migration.rb.tt +14 -0
  6. data/lib/generators/mistri/install/templates/model.rb.tt +4 -0
  7. data/lib/generators/mistri/mcp/mcp_generator.rb +57 -0
  8. data/lib/generators/mistri/mcp/templates/migration.rb.tt +27 -0
  9. data/lib/generators/mistri/mcp/templates/model.rb.tt +63 -0
  10. data/lib/mistri/abort_signal.rb +63 -0
  11. data/lib/mistri/agent.rb +389 -0
  12. data/lib/mistri/budget.rb +29 -0
  13. data/lib/mistri/compaction.rb +78 -0
  14. data/lib/mistri/compactor.rb +182 -0
  15. data/lib/mistri/content.rb +89 -0
  16. data/lib/mistri/edit.rb +238 -0
  17. data/lib/mistri/errors.rb +94 -0
  18. data/lib/mistri/event.rb +54 -0
  19. data/lib/mistri/mcp/client.rb +156 -0
  20. data/lib/mistri/mcp/oauth.rb +286 -0
  21. data/lib/mistri/mcp/wires.rb +164 -0
  22. data/lib/mistri/mcp.rb +96 -0
  23. data/lib/mistri/memory.rb +26 -0
  24. data/lib/mistri/message.rb +90 -0
  25. data/lib/mistri/models.rb +43 -0
  26. data/lib/mistri/partial_json.rb +210 -0
  27. data/lib/mistri/providers/anthropic/assembler.rb +205 -0
  28. data/lib/mistri/providers/anthropic/serializer.rb +106 -0
  29. data/lib/mistri/providers/anthropic.rb +106 -0
  30. data/lib/mistri/providers/fake.rb +109 -0
  31. data/lib/mistri/providers/gemini/assembler.rb +163 -0
  32. data/lib/mistri/providers/gemini/serializer.rb +109 -0
  33. data/lib/mistri/providers/gemini.rb +73 -0
  34. data/lib/mistri/providers/openai/assembler.rb +205 -0
  35. data/lib/mistri/providers/openai/serializer.rb +104 -0
  36. data/lib/mistri/providers/openai.rb +72 -0
  37. data/lib/mistri/reminder.rb +36 -0
  38. data/lib/mistri/result.rb +32 -0
  39. data/lib/mistri/retry_policy.rb +47 -0
  40. data/lib/mistri/schema.rb +162 -0
  41. data/lib/mistri/session.rb +124 -0
  42. data/lib/mistri/sinks/action_cable.rb +30 -0
  43. data/lib/mistri/sinks/coalesced.rb +61 -0
  44. data/lib/mistri/sinks/sse.rb +26 -0
  45. data/lib/mistri/skill.rb +15 -0
  46. data/lib/mistri/skills.rb +81 -0
  47. data/lib/mistri/sse.rb +50 -0
  48. data/lib/mistri/stop_reason.rb +25 -0
  49. data/lib/mistri/stores/active_record.rb +47 -0
  50. data/lib/mistri/stores/jsonl.rb +37 -0
  51. data/lib/mistri/stores/memory.rb +22 -0
  52. data/lib/mistri/sub_agent.rb +211 -0
  53. data/lib/mistri/tool.rb +95 -0
  54. data/lib/mistri/tool_call.rb +18 -0
  55. data/lib/mistri/tool_context.rb +15 -0
  56. data/lib/mistri/tool_executor.rb +87 -0
  57. data/lib/mistri/tool_result.rb +23 -0
  58. data/lib/mistri/tools/edit_file.rb +37 -0
  59. data/lib/mistri/tools/find_in_file.rb +36 -0
  60. data/lib/mistri/tools/list_files.rb +16 -0
  61. data/lib/mistri/tools/read_file.rb +38 -0
  62. data/lib/mistri/tools/read_memory.rb +16 -0
  63. data/lib/mistri/tools/update_memory.rb +22 -0
  64. data/lib/mistri/tools/write_file.rb +20 -0
  65. data/lib/mistri/tools.rb +50 -0
  66. data/lib/mistri/transport.rb +228 -0
  67. data/lib/mistri/usage.rb +79 -0
  68. data/lib/mistri/version.rb +1 -1
  69. data/lib/mistri/workspace/active_record.rb +47 -0
  70. data/lib/mistri/workspace/directory.rb +52 -0
  71. data/lib/mistri/workspace/memory.rb +40 -0
  72. data/lib/mistri/workspace/single.rb +48 -0
  73. data/lib/mistri.rb +89 -0
  74. metadata +79 -10
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2ef47e99ddd310ccf5ad44af48a9626865b82760040a84cc096d2ed539b06c8a
4
- data.tar.gz: 92e0f8c9d928493f59aabc8b39563f63454be7bd870f1d9ebbf3199c044addb9
3
+ metadata.gz: 53547d685a896819e552e9911249b042715686f47b1c61a3e01ca2a9542f915d
4
+ data.tar.gz: d902f802d0272fe0ba99525946ee50169c660d43c99d33790061231854cae302
5
5
  SHA512:
6
- metadata.gz: 1ed7204e694fe68960f0e6e10b875a937f7c1e90abde131b8e06bd1dd489bb9136a86dba7b34a9474044ebf1fe435220520ccad86cf4256ddb884cd2d1f6d6fb
7
- data.tar.gz: f6a0b5a222507ad487eb2572e36630cd677ac5b659045f084407ef369401fa8a9cf5eacbebe730b814e6ea98f661e9ade7ec7df8c0f33370e6fce9a5483dec92
6
+ metadata.gz: c88a9318c689bd4c8dc2b010c8e9e3c939989971d27befcdc60ff45a195cbd414e25564e866d85a2b495d1a2edb515d4892e2060d204382fc93bc430797b2508
7
+ data.tar.gz: cb8011d2421bdf066a68d0ec3b76c4d6d51ee06a7ebb49479f28d22da92fa04694115f762c3e132a11c17b69c128141b2b652d7cf9148049180cd0cb0ae7aae8
data/CHANGELOG.md CHANGED
@@ -5,6 +5,219 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.2.0] - 2026-07-05
9
+
10
+ - Repository hygiene: coverage floor enforced in CI (simplecov, 90% line),
11
+ contributing/security/conduct docs, issue and PR templates, Dependabot,
12
+ and rubygems documentation and bug tracker links.
13
+
14
+ - Per-tool timeouts: Tool.define(..., timeout: 30) answers in band when a
15
+ handler stalls, so one hung tool cannot stall the run.
16
+ - :tool_result events carry duration (seconds) for executed tools, feeding
17
+ latency metrics straight from any sink.
18
+
19
+ - Mistri::Reminder.every(3, text): a periodic tail reminder for long runs,
20
+ riding transform_context; due by completed assistant turns, fresh on the
21
+ wire each time, never persisted.
22
+
23
+ - Tool hooks: before_tool(call, context) blocks a call by returning the
24
+ reason as a String, answered to the model in band; it outranks the
25
+ approval gate and screens approved calls again at settle time, so an
26
+ aged approval never beats current policy. after_tool(call, result,
27
+ context) may replace a result (both channels), nil keeps it. Hooks that
28
+ raise fail safe: before blocks, after answers in band.
29
+ - transform_context accepts an array of transforms, applied in order.
30
+
31
+ - Result#usage: every run reports its own token and cost accounting,
32
+ summing persisted turns and compaction calls; task sums across its fix
33
+ passes. Hosts meter a run without walking the session.
34
+
35
+ - MCP stdio wire: Client.new(command: [...], env: {...}) spawns a local
36
+ server as a child process speaking line-delimited JSON-RPC, credentials
37
+ in its environment per spec. Dying servers and non-protocol stdout fail
38
+ loudly; close terminates the child.
39
+
40
+ - MCP connections out of the box: Mistri::MCP::OAuth.start/.complete/
41
+ .refresh are storage-agnostic services implementing the spec's OAuth 2.1
42
+ subset (challenge and well-known discovery, RFC 8414 metadata with an
43
+ OpenID fallback, dynamic client registration as the host application,
44
+ PKCE with resource indicators, rotating refresh). `rails generate
45
+ mistri:mcp YourModel` creates a host-named connection model whose rows
46
+ carry their own flow state and encrypted tokens, with connection.tools
47
+ bridging straight into an agent and refreshing ahead of expiry.
48
+
49
+ - MCP bridge: Mistri::MCP::Client speaks Streamable HTTP (initialize
50
+ handshake, tools/list with pagination, tools/call, sessions with
51
+ transparent expiry recovery, JSON or SSE responses) with zero new
52
+ dependencies. Auth is a headers hash or a token string-or-lambda; a
53
+ lambda re-resolves once on 401, so host refresh logic lives in one place.
54
+ Mistri::MCP.tools bridges any server (or any duck-typed client, the
55
+ official mcp gem included) into Mistri tools with allow/deny lists, name
56
+ prefixing, and per-tool approval gates, so a third-party write tool can
57
+ ride the human-approval arc.
58
+
59
+ ## [0.1.0] - 2026-07-05
60
+
61
+ - Live integration harness: `rake integration` runs every feature end to
62
+ end against real provider APIs, once per model in the matrix
63
+ (MISTRI_INTEGRATION_MODELS overrides the default trio). Scenarios assert
64
+ that generated codenames flowed through the machinery, so answers prove
65
+ information flow rather than model knowledge. The default `rake test`
66
+ stays hermetic.
67
+ - The spawn tool's child models now come only from a host allowlist
68
+ (`models:` on SubAgent.spawner); without one no model choice is offered,
69
+ so a hallucinated model id can never construct a provider. Found by the
70
+ integration harness on its first run.
71
+ - The skills system-prompt section instructs selection more firmly.
72
+
73
+ - Sub-agents: delegation with a clean context (#2). Mistri::SubAgent names
74
+ a curated specialist (own provider/system/tools, optional schema: for
75
+ validated JSON answers); SubAgent.spawner is the open spawn_agent tool
76
+ where the model writes the child's instructions, grants a tool subset,
77
+ and may pick a model. Children run fresh sessions on the caller's store,
78
+ linked in its transcript and on the tool result's ui channel; their
79
+ events stream into the parent tagged with origin. Parallel spawn calls
80
+ fan out on the executor pool. Approval-gated tools are refused inside
81
+ children; gate the delegation itself instead.
82
+ - Tool handlers now receive an optional second argument, ToolContext
83
+ (session, signal, emit); procs ignore it invisibly and strict lambdas
84
+ keep their old arity. Events gained an origin field.
85
+
86
+ - Task mode: Agent#task(input, schema:) runs an exchange that must end in
87
+ JSON matching the schema — tools run as usual, providers constrain the
88
+ final answer natively where supported (Anthropic output_config, OpenAI
89
+ text.format strict, Gemini responseJsonSchema when no tools), and the
90
+ answer validates client-side everywhere. A violation goes back to the
91
+ model once (fixes:), then raises SchemaError; Result#output carries the
92
+ validated value. run(output_schema:) exposes raw constrained runs.
93
+ - Schema.violations (a zero-dependency validator for the supported subset,
94
+ with model-feedable error messages) and Schema.strict (wire preparation:
95
+ additionalProperties false everywhere, all-required for OpenAI strict).
96
+
97
+ - Skills: Mistri::Skill and Skills.load (a directory of SKILL.md folders or
98
+ flat .md files, flat-frontmatter name/description). Pass skills: to the
99
+ Agent (array or path): descriptions join the system prompt and a
100
+ read_skill tool serves full bodies on demand, so a skill library costs
101
+ one line each until used. Hosts with skills in a database construct
102
+ Skill objects directly.
103
+
104
+ - Rails integration: `rails generate mistri:install YourModel` creates a
105
+ host-named entry model and its migration for Stores::ActiveRecord
106
+ (MEDIUMTEXT payload on MySQL-family adapters). Streaming sinks under
107
+ Mistri::Sinks — ActionCable (lazy server, injectable), SSE (outbound
108
+ frames to any IO), and Coalesced (merges delta bursts to UI speed) — all
109
+ pure Ruby, usable as `agent.run(input, &sink)`. No Railtie: generators
110
+ auto-discover and everything else duck-types.
111
+
112
+ - Retry policy: transient turn failures (429/5xx/529, timeouts, dropped or
113
+ truncated streams) retry with jittered backoff, honoring retry-after. On
114
+ by default (retries: on the Agent; false disables, or pass a tuned
115
+ RetryPolicy). Failed attempts record as retry entries and emit :retry
116
+ events but never become messages, so retries stay invisible to the model.
117
+ - Errored messages now carry a machine-readable error field ({type, status,
118
+ retry_after}) alongside error_message; in-stream provider errors keep
119
+ their wire classification (Anthropic overloaded, OpenAI rate limits,
120
+ Gemini status codes) instead of folding into prose.
121
+
122
+ - Two-channel tool results: a handler may return Mistri::ToolResult with
123
+ content for the model and ui for the host. The ui payload rides the tool
124
+ message and its :tool_result event, persists with the session for
125
+ transcript re-renders, and never reaches a provider.
126
+
127
+ - Context compaction: sessions compact automatically when the context grows
128
+ into the reserve headroom (compaction: on the Agent, on by default when the
129
+ model's window is known; pass false to disable). The provider writes a
130
+ visible structured summary; a compaction entry redirects replay to summary
131
+ plus kept tail while the full history stays in the store. Cuts land only on
132
+ user messages so tool pairs never split, parked approvals stay resumable,
133
+ and second compactions update the first summary. Agent#compact is the
134
+ manual button, Agent#context_usage the {tokens, window, fraction} gauge,
135
+ and :compacting/:compaction the events.
136
+
137
+ - transform_context: an Agent option that reshapes what the model sees each
138
+ turn (reminders, redaction, windowing) while the stored transcript stays
139
+ untouched. The lambda receives the replay messages and returns the messages
140
+ to send; it must keep tool calls paired with their results.
141
+
142
+ - Steering: Session#steer queues a user message from any process while a run
143
+ is live. The loop folds pending steers into the transcript at the next turn
144
+ boundary, and one that arrives as the model finishes cleanly extends the
145
+ run so it gets answered instead of dangling. Steers compose with approval
146
+ suspensions: queue a thought, approve, resume.
147
+
148
+ - Human-in-the-loop approval: a tool marked needs_approval (true or a
149
+ predicate on its arguments) suspends the run instead of executing. Runs
150
+ return a Result immediately; decisions are recorded on the session from any
151
+ process (approve/deny), and resume settles them and continues. No thread
152
+ ever waits on a human.
153
+ - Every run now returns a Mistri::Result (completed, awaiting_approval,
154
+ aborted, budget, or error) that delegates text and stop_reason to its final
155
+ message.
156
+ - Session entries are normalized to one canonical JSON shape across all
157
+ stores.
158
+
159
+ - Budget stops report stop_reason :budget, distinct from a user abort.
160
+ - Tool results that are arrays of data serialize as JSON; empty input and
161
+ duplicate tool names fail loudly at the boundary.
162
+
163
+ - `Mistri::Memory` and `Mistri::Tools.memory`: durable knowledge across
164
+ sessions, read and rewritten whole, living wherever the host points it.
165
+
166
+ - `Mistri::Workspace`: the document store agents work in, with memory,
167
+ directory, ActiveRecord, and single-document backends, so editing a
168
+ database column works exactly like editing a file.
169
+ - `Mistri::Tools.files`: the built-in document tools (read_file, write_file,
170
+ edit_file, find_in_file, list_files). The edit tool speaks the flat
171
+ old_string/new_string shape models are trained on, tolerates alias keys,
172
+ and reports misses with the closest region and its exact difference.
173
+ - `Mistri::Edit.replace`: single-edit replacement with replace_all, newline
174
+ and BOM preservation, and near-miss diagnostics.
175
+
176
+ - Truncated streams now fail as retryable errors on every provider instead of
177
+ reading as user cancellations, and their tool calls pair without executing.
178
+ - Provider error turns carry the HTTP status and response body.
179
+ - Budgets measure every ceiling per run, including wall clock.
180
+
181
+ - `Mistri::Edit`: pure fuzzy text replacement with a uniqueness guarantee,
182
+ so an edit never silently changes the wrong region; the string core for a
183
+ workspace-backed edit tool that works against a database as well as a file.
184
+
185
+ - `Mistri.agent` and `Mistri.provider`: build an agent or provider from a model
186
+ id, inferring the provider and reading its key from the environment.
187
+
188
+ - Aborted and truncated turns now replay without provider errors: unusable
189
+ thinking degrades to text, empty blocks are dropped, every tool call is
190
+ paired, and budget-only models skip adaptive thinking.
191
+
192
+ - The error hierarchy: every Mistri failure rescues as `Mistri::Error`.
193
+ - The message protocol: immutable content blocks (text, thinking, image, tool call),
194
+ messages with provider identity, usage accounting with cost math, stop reasons.
195
+ - The streaming event union: twelve event types, each carrying an immutable
196
+ partial-message snapshot.
197
+ - `Mistri::Providers::Fake`: a scriptable provider for hermetic host tests.
198
+ - `Mistri::AbortSignal`: a thread-safe cancel latch with abort callbacks.
199
+ - `Mistri::SSE`: an incremental server-sent-events decoder.
200
+ - `Mistri::Transport`: a persistent per-provider streaming connection with
201
+ status-mapped errors and hard abort of hung streams.
202
+ - `Mistri::PartialJson`: best-effort parsing of in-flight tool arguments.
203
+ - `Mistri::Models`: a capability catalog with graceful passthrough, so unknown
204
+ models work the day they ship.
205
+ - `Mistri::Providers::Anthropic`: the Messages API streamed, with adaptive
206
+ thinking, prompt caching, signature round-trips, and eager tool-input
207
+ streaming.
208
+ - `Mistri::Providers::OpenAI`: the Responses API streamed and stateless, with
209
+ encrypted reasoning replay and thinking summaries.
210
+ - `Mistri::Providers::Gemini`: generateContent streamed, with unconstrained
211
+ thinking by default and verbatim thought-signature replay.
212
+ - `Mistri::Agent`: the streaming tool-calling loop, persisting each turn as it
213
+ completes so aborts and crashes resume without repair.
214
+ - `Mistri::Tool` and `Mistri::Schema`: define tools with a raw JSON Schema or a
215
+ Ruby schema block; results may be text, JSON, or content blocks.
216
+ - `Mistri::Session` with pluggable stores (`Memory`, `JSONL`, and an optional
217
+ `ActiveRecord` adapter for the host's own database).
218
+ - `Mistri::Budget`: opt-in ceilings on turns, tokens, cost, and wall-clock;
219
+ nothing is enforced unless the host sets it.
220
+
8
221
  ## [0.0.3] - 2026-07-04
9
222
 
10
223
  - Repository moved to github.com/mcheemaa/mistri.
@@ -13,3 +226,5 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
13
226
  ## [0.0.1] - 2026-07-04
14
227
 
15
228
  - Reserved the gem name.
229
+
230
+ [0.1.0]: https://github.com/mcheemaa/mistri/releases/tag/v0.1.0
data/README.md CHANGED
@@ -1,5 +1,369 @@
1
- # Mistri
1
+ <h1 align="center">مستری</h1>
2
2
 
3
- **Mistri (مستری)** — the fixer. In Urdu, the mistri is the skilled tradesperson you call when you need something built or repaired: the one who actually gets it done.
3
+ <p align="center"><strong>mistri</strong>, the agent harness for Ruby applications.</p>
4
4
 
5
- Mistri is an agent harness for Ruby applications. First release coming soon.
5
+ <p align="center">
6
+ <a href="https://rubygems.org/gems/mistri"><img alt="Gem Version" src="https://img.shields.io/gem/v/mistri"></a>
7
+ <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>
8
+ <a href="https://codecov.io/gh/mcheemaa/mistri"><img alt="Coverage" src="https://codecov.io/gh/mcheemaa/mistri/graph/badge.svg"></a>
9
+ <a href="mistri.gemspec"><img alt="Ruby >= 3.2" src="https://img.shields.io/badge/ruby-%3E%3D%203.2-CC342D"></a>
10
+ <a href="Gemfile"><img alt="Runtime dependencies: zero" src="https://img.shields.io/badge/runtime_deps-0-brightgreen"></a>
11
+ <a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-blue.svg"></a>
12
+ </p>
13
+
14
+ A mistri (Urdu: مستری) is the fixer: the skilled tradesperson who actually
15
+ gets it done. This one lives inside your app, not in a terminal. It runs the
16
+ model loop, executes tools, streams every event, persists sessions to your
17
+ own database, and pauses for a human when a tool needs approval, all with
18
+ zero runtime gem dependencies.
19
+
20
+ ```ruby
21
+ require "mistri"
22
+
23
+ weather = Mistri::Tool.define(
24
+ "get_weather", "Current weather for a city.",
25
+ schema: -> { string :city, "City name", required: true },
26
+ ) do |args|
27
+ Weather.for(args["city"])
28
+ end
29
+
30
+ agent = Mistri.agent("claude-opus-4-8", tools: [weather]) # reads ANTHROPIC_API_KEY
31
+
32
+ agent.run("What should I wear in Lahore today?") do |event|
33
+ print event.delta if event.type == :text_delta
34
+ end
35
+ ```
36
+
37
+ ## Why Mistri
38
+
39
+ - **Built for applications.** Sessions are durable, append-only records in
40
+ your own store. Runs stop, resume, steer, and compact from any process.
41
+ - **Fire-and-forget human approval.** A gated tool suspends the run and
42
+ returns immediately. The approval can arrive two days later from a bare
43
+ web request; nothing sleeps waiting.
44
+ - **Three providers, frontier-deep.** Anthropic, OpenAI, and Gemini, each
45
+ streamed natively with thinking, prompt caching, parallel tool calls, and
46
+ constrained JSON output. One message model across all three.
47
+ - **Zero runtime dependencies.** Plain Ruby all the way down.
48
+ - **Verified against real APIs.** A live integration harness runs every
49
+ feature end to end on every provider (`rake integration`).
50
+
51
+ ## Install
52
+
53
+ ```ruby
54
+ gem "mistri"
55
+ ```
56
+
57
+ ## Sixty-second start
58
+
59
+ ```ruby
60
+ agent = Mistri.agent("claude-opus-4-8")
61
+ result = agent.run("Name three Ruby web frameworks.")
62
+ puts result.text
63
+ ```
64
+
65
+ `Mistri.agent` infers the provider from the model id (`claude-*`, `gpt-*`,
66
+ `gemini-*`) and reads the matching key (`ANTHROPIC_API_KEY`,
67
+ `OPENAI_API_KEY`, `GEMINI_API_KEY`); pass `api_key:` to set it explicitly.
68
+ Every run returns a `Result`: `completed?`, `awaiting_approval?`,
69
+ `aborted?`, `errored?`, with `text` and (for tasks) `output`.
70
+
71
+ ## Tools
72
+
73
+ A tool is a name, a description, an argument schema, and a block. The block
74
+ returns a String, a Hash (sent as JSON), or content such as an image. The
75
+ agent calls tools, feeds results back, and loops until the model answers;
76
+ independent calls in a turn run in parallel.
77
+
78
+ ```ruby
79
+ weather = Mistri::Tool.define("get_weather", "Current weather for a city.", schema: lambda {
80
+ string :city, "City name", required: true
81
+ string :units, "Temperature units", enum: %w[celsius fahrenheit]
82
+ }) do |args|
83
+ Weather.for(args["city"], units: args["units"] || "celsius")
84
+ end
85
+ ```
86
+
87
+ A tool can speak on two channels: `content` for the model, `ui` for your
88
+ interface. The `ui` payload rides the `:tool_result` event and persists with
89
+ the session, but never reaches a provider:
90
+
91
+ ```ruby
92
+ Mistri::Tool.define("edit_page", "Applies a page edit.") do |args|
93
+ page = apply(args)
94
+ Mistri::ToolResult.new(content: "Saved.", ui: { "html" => page })
95
+ end
96
+ ```
97
+
98
+ ## Human approval
99
+
100
+ Mark a tool `needs_approval: true` (or a predicate on its arguments) and the
101
+ run suspends instead of executing it, instantly, with no thread waiting.
102
+ The decision is a one-line session write from any process, any time later;
103
+ `resume` settles it and carries on.
104
+
105
+ ```ruby
106
+ send_gift = Mistri::Tool.define("send_gift", "Sends a real gift.",
107
+ needs_approval: ->(args) { args["amount"].to_i > 100 }) do |args|
108
+ Gifts.send!(args)
109
+ end
110
+
111
+ result = agent.run("Send Ana a $200 gift")
112
+ result.awaiting_approval? # => true; nothing executed
113
+
114
+ # Days later, in a controller:
115
+ Mistri::Session.new(store:, id: session_id).approve(call_id) # or .deny(call_id, note: "...")
116
+
117
+ # Then, in a worker:
118
+ Mistri.agent("claude-opus-4-8", tools: tools, session: reloaded).resume
119
+ ```
120
+
121
+ The harness renders nothing: it emits an `:approval_needed` event and your
122
+ app draws the UI.
123
+
124
+ ## Steering
125
+
126
+ Queue a message into a running exchange from any process. It folds into the
127
+ conversation at the next turn boundary; one that arrives as the model
128
+ finishes cleanly extends the run so it gets answered.
129
+
130
+ ```ruby
131
+ Mistri::Session.new(store:, id: session_id).steer("Make the headline blue instead.")
132
+ ```
133
+
134
+ ## Sessions
135
+
136
+ A session is the durable record of a run: an append-only entry log over a
137
+ pluggable store (memory, JSONL files, or your database).
138
+
139
+ ```ruby
140
+ store = Mistri::Stores::JSONL.new("tmp/sessions")
141
+ session = Mistri::Session.new(store:)
142
+
143
+ agent = Mistri.agent("claude-opus-4-8", session:)
144
+ agent.run("Start a haiku about the sea.")
145
+
146
+ # Later, even in another process: reload by id and continue.
147
+ resumed = Mistri.agent("claude-opus-4-8", session: Mistri::Session.new(store:, id: session.id))
148
+ resumed.run("Now finish it.")
149
+ ```
150
+
151
+ In Rails, generate a model (name it whatever you like) and use the
152
+ ActiveRecord store:
153
+
154
+ ```console
155
+ $ bin/rails generate mistri:install AgentEntry
156
+ ```
157
+
158
+ ```ruby
159
+ require "mistri/stores/active_record"
160
+ store = Mistri::Stores::ActiveRecord.new(AgentEntry)
161
+ ```
162
+
163
+ ## Compaction
164
+
165
+ Long sessions survive their context window: when the conversation grows into
166
+ the reserve headroom, the provider writes a visible structured summary and
167
+ replay continues from it. The full history stays in your store for
168
+ transcript views. On by default whenever the model's window is known;
169
+ `compaction: false` disables it.
170
+
171
+ ```ruby
172
+ agent.context_usage # => { tokens: 141_000, window: 200_000, fraction: 0.705 }
173
+ agent.compact # the manual button
174
+ ```
175
+
176
+ `:compacting` and `:compaction` events carry the summary, so users see
177
+ exactly what the model still remembers.
178
+
179
+ ## Task mode
180
+
181
+ A run that must end in JSON matching a schema. Tools run as usual; providers
182
+ constrain the final answer natively where they can, and the answer is
183
+ validated client-side everywhere. A violation goes back to the model once,
184
+ then raises. You get a guaranteed shape or a loud error, never silence.
185
+
186
+ ```ruby
187
+ schema = {
188
+ type: "object",
189
+ properties: { "tiers" => { type: "array", items: { type: "string" } } },
190
+ required: ["tiers"],
191
+ }
192
+
193
+ result = agent.task("Extract the pricing tiers from this page.", schema: schema)
194
+ result.output # => { "tiers" => [...] }, parsed and validated
195
+ ```
196
+
197
+ ## Skills
198
+
199
+ Expert playbooks with progressive disclosure: each skill costs one line in
200
+ the system prompt until the model decides it is relevant and pulls the full
201
+ body through an auto-provided `read_skill` tool.
202
+
203
+ ```ruby
204
+ agent = Mistri.agent("claude-opus-4-8", skills: "app/skills") # or an array of Mistri::Skill
205
+ ```
206
+
207
+ A skill is a `SKILL.md` (or flat `.md`) with `name:`/`description:`
208
+ frontmatter, or built from database rows with
209
+ `Mistri::Skill.new(name:, description:, body:)`.
210
+
211
+ ## Sub-agents
212
+
213
+ Delegate to a child agent with a clean context: exploration fills the
214
+ child's window, and only the final answer returns. Children run on their own
215
+ sessions in your store, linked in the parent transcript; their events stream
216
+ into the parent tagged with an `origin`.
217
+
218
+ ```ruby
219
+ researcher = Mistri::SubAgent.new(
220
+ name: "researcher", description: "Reads pages and answers factual questions.",
221
+ provider: Mistri.provider("claude-haiku-4-5-20251001"), # cheaper model for grunt work
222
+ system: "Research. Report findings only.", tools: [fetch_page],
223
+ )
224
+ agent = Mistri.agent("claude-opus-4-8", tools: [researcher.tool])
225
+ ```
226
+
227
+ Or hand the model an open spawn tool and let it compose its own workers:
228
+ instructions, a tool subset, and a host-allowlisted model per child.
229
+ Several spawns in one turn fan out in parallel:
230
+
231
+ ```ruby
232
+ spawn = Mistri::SubAgent.spawner(provider: provider, tools: [fetch_page, search])
233
+ ```
234
+
235
+ ## Editing documents
236
+
237
+ The document tools (`read_file`, `edit_file`, `write_file`, `find_in_file`,
238
+ `list_files`) work over a workspace: a directory, memory, ActiveRecord, or
239
+ a single value anywhere, like one database column holding a page:
240
+
241
+ ```ruby
242
+ workspace = Mistri::Workspace::Single.new(
243
+ read: -> { page.html },
244
+ write: ->(html) { page.update!(html: html) },
245
+ path: "hero.html",
246
+ )
247
+ agent = Mistri.agent("claude-opus-4-8", tools: Mistri::Tools.files(workspace))
248
+ ```
249
+
250
+ The edit engine matches exactly, then whitespace-tolerantly; an ambiguous
251
+ match refuses (never silently edits the wrong place), and a near-miss error
252
+ names the closest region so the model's retry is one-shot.
253
+
254
+ ## MCP
255
+
256
+ Bridge any Model Context Protocol server's tools into an agent. The client
257
+ speaks Streamable HTTP with zero new dependencies; auth is a token string
258
+ or a lambda that re-resolves once on 401, so refresh logic lives in one
259
+ place. Approval gates compose: a third-party write tool can require a
260
+ human.
261
+
262
+ ```ruby
263
+ client = Mistri::MCP::Client.new(url: "https://mcp.linear.app/mcp",
264
+ token: -> { connection.bearer_token })
265
+ tools = Mistri::MCP.tools(client, prefix: "linear",
266
+ gates: { "create_issue" => true })
267
+
268
+ agent = Mistri.agent("claude-opus-4-8", tools: tools)
269
+ ```
270
+
271
+ Local stdio servers spawn as child processes, credentials in their
272
+ environment. That is also the whole "give the agent a browser" story:
273
+
274
+ ```ruby
275
+ browser = Mistri::MCP::Client.new(
276
+ command: ["npx", "-y", "@playwright/mcp@latest", "--browser", "chrome", "--headless"],
277
+ )
278
+ agent = Mistri.agent("claude-opus-4-8",
279
+ tools: Mistri::MCP.tools(browser, allow: %w[browser_navigate browser_snapshot]))
280
+ ```
281
+
282
+ For the full connect-your-tools story in Rails, generate a connection model
283
+ (name it whatever you like):
284
+
285
+ ```console
286
+ $ bin/rails generate mistri:mcp McpConnection
287
+ ```
288
+
289
+ Each row is one server connection carrying its own OAuth flow state and
290
+ encrypted tokens. The OAuth services underneath (`Mistri::MCP::OAuth.start`,
291
+ `.complete`, `.refresh`) are storage-agnostic, so the same flow works from a
292
+ controller, a GraphQL mutation, or a job. Registration happens as your
293
+ application: `client_name:` is yours to set.
294
+
295
+ ```ruby
296
+ connection, authorize_url = McpConnection.connect(
297
+ name: "Linear", url: params[:url],
298
+ client_name: "YourApp", redirect_uri: mcp_callback_url,
299
+ )
300
+ # redirect the user to authorize_url; then, in the callback:
301
+ connection = McpConnection.complete(state: params[:state], code: params[:code])
302
+
303
+ agent = Mistri.agent("claude-opus-4-8", tools: connection.tools(prefix: "linear"))
304
+ ```
305
+
306
+ ## Streaming into Rails
307
+
308
+ Sinks bridge the event stream to a transport, and compose as blocks:
309
+
310
+ ```ruby
311
+ cable = Mistri::Sinks::ActionCable.new("agent_#{session.id}")
312
+ sink = Mistri::Sinks::Coalesced.new(cable) # merges token bursts to UI speed
313
+
314
+ agent.run(input, &sink)
315
+ ```
316
+
317
+ `Mistri::Sinks::SSE.new(response.stream)` does the same for
318
+ `ActionController::Live`. There is no Railtie and nothing to configure;
319
+ the generator and stores duck-type into any app.
320
+
321
+ ## Stopping, budgets, reliability
322
+
323
+ ```ruby
324
+ # Trip the signal from anywhere; the partial turn persists, resume is clean.
325
+ signal = Mistri::AbortSignal.new
326
+ agent.run("Draft a long essay.", signal: signal)
327
+
328
+ # Ceilings are opt-in and off by default.
329
+ budget = Mistri::Budget.new(turns: 20, cost_usd: 2.00)
330
+
331
+ # Transient failures (429, 5xx, timeouts) retry with backoff, invisibly to
332
+ # the model. On by default; retries: false disables.
333
+ policy = Mistri::RetryPolicy.new(attempts: 3)
334
+ ```
335
+
336
+ ## Images and provider options
337
+
338
+ ```ruby
339
+ photo = Mistri::Content::Image.from_bytes(File.binread("chart.png"), mime_type: "image/png")
340
+ agent.run("What trend does this chart show?", images: [photo])
341
+
342
+ Mistri.agent("gpt-5.5", provider_options: { reasoning: { effort: "high" } })
343
+ Mistri.agent("claude-opus-4-8", provider_options: { cache: false })
344
+ ```
345
+
346
+ ## Verified for real
347
+
348
+ `rake test` is hermetic and fast. `rake integration` runs every feature end
349
+ to end against real provider APIs, once per model in the matrix. Scenarios
350
+ assert that coined codenames (a ghost of a word like `Wraithowyn` exists in
351
+ no training data) flowed through tool results, summaries, and child agents:
352
+ proof of information flow, not model knowledge.
353
+
354
+ ```console
355
+ $ MISTRI_INTEGRATION_MODELS=claude-opus-4-8,gpt-5.5 bundle exec rake integration
356
+ ```
357
+
358
+ ## Roadmap
359
+
360
+ `0.2.0`: an MCP client bridge, so any MCP server's tools plug into an agent.
361
+
362
+ ## Credits
363
+
364
+ Mistri's architecture is informed by [pi](https://github.com/badlogic/pi-mono)
365
+ by Mario Zechner. See NOTICE.
366
+
367
+ ## License
368
+
369
+ MIT. See LICENSE.
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/named_base"
5
+ require "rails/generators/active_record"
6
+
7
+ module Mistri
8
+ module Generators
9
+ # bin/rails generate mistri:install AgentEntry
10
+ #
11
+ # Creates the session-entry model, named by the host application, and its
12
+ # migration, ready for Mistri::Stores::ActiveRecord.
13
+ class InstallGenerator < Rails::Generators::NamedBase
14
+ include ActiveRecord::Generators::Migration
15
+
16
+ source_root File.expand_path("templates", __dir__)
17
+
18
+ argument :name, type: :string, default: "MistriEntry"
19
+
20
+ def create_model
21
+ template "model.rb.tt", File.join("app/models", class_path, "#{file_name}.rb")
22
+ end
23
+
24
+ def create_migration_file
25
+ migration_template "migration.rb.tt",
26
+ File.join(db_migrate_path, "create_#{table_name}.rb")
27
+ end
28
+
29
+ def show_wiring
30
+ say <<~NOTE
31
+
32
+ Wire the store with your model:
33
+
34
+ store = Mistri::Stores::ActiveRecord.new(#{class_name})
35
+ session = Mistri::Session.new(store: store)
36
+
37
+ (require "mistri/stores/active_record" where you build it.)
38
+
39
+ NOTE
40
+ end
41
+
42
+ private
43
+
44
+ # MySQL TEXT caps at 64KB, which a single large tool result can blow
45
+ # through; MEDIUMTEXT holds 16MB. Postgres text is unbounded.
46
+ def payload_size
47
+ adapter = ::ActiveRecord::Base.connection_db_config.adapter.to_s
48
+ adapter.match?(/mysql|trilogy/) ? ", size: :medium" : ""
49
+ rescue StandardError
50
+ ""
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,14 @@
1
+ class Create<%= table_name.camelize %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :<%= table_name %> do |t|
4
+ t.string :session_id, null: false, index: true
5
+ t.integer :position, null: false
6
+ t.text :payload<%= payload_size %>, null: false
7
+ t.timestamps
8
+ end
9
+
10
+ # One session has one writer; a colliding append must raise, never
11
+ # silently reorder entries.
12
+ add_index :<%= table_name %>, [:session_id, :position], unique: true
13
+ end
14
+ end
@@ -0,0 +1,4 @@
1
+ # Session entries for Mistri agents; read and written through
2
+ # Mistri::Stores::ActiveRecord, one row per entry, append-only.
3
+ class <%= class_name %> < ApplicationRecord
4
+ end