mistri 0.0.2 → 0.1.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +177 -0
  3. data/NOTICE +9 -0
  4. data/README.md +314 -3
  5. data/lib/generators/mistri/install/install_generator.rb +54 -0
  6. data/lib/generators/mistri/install/templates/migration.rb.tt +14 -0
  7. data/lib/generators/mistri/install/templates/model.rb.tt +4 -0
  8. data/lib/mistri/abort_signal.rb +63 -0
  9. data/lib/mistri/agent.rb +340 -0
  10. data/lib/mistri/budget.rb +29 -0
  11. data/lib/mistri/compaction.rb +78 -0
  12. data/lib/mistri/compactor.rb +182 -0
  13. data/lib/mistri/content.rb +89 -0
  14. data/lib/mistri/edit.rb +238 -0
  15. data/lib/mistri/errors.rb +94 -0
  16. data/lib/mistri/event.rb +50 -0
  17. data/lib/mistri/memory.rb +26 -0
  18. data/lib/mistri/message.rb +90 -0
  19. data/lib/mistri/models.rb +43 -0
  20. data/lib/mistri/partial_json.rb +210 -0
  21. data/lib/mistri/providers/anthropic/assembler.rb +205 -0
  22. data/lib/mistri/providers/anthropic/serializer.rb +106 -0
  23. data/lib/mistri/providers/anthropic.rb +106 -0
  24. data/lib/mistri/providers/fake.rb +109 -0
  25. data/lib/mistri/providers/gemini/assembler.rb +163 -0
  26. data/lib/mistri/providers/gemini/serializer.rb +109 -0
  27. data/lib/mistri/providers/gemini.rb +73 -0
  28. data/lib/mistri/providers/openai/assembler.rb +205 -0
  29. data/lib/mistri/providers/openai/serializer.rb +104 -0
  30. data/lib/mistri/providers/openai.rb +72 -0
  31. data/lib/mistri/result.rb +30 -0
  32. data/lib/mistri/retry_policy.rb +47 -0
  33. data/lib/mistri/schema.rb +162 -0
  34. data/lib/mistri/session.rb +124 -0
  35. data/lib/mistri/sinks/action_cable.rb +30 -0
  36. data/lib/mistri/sinks/coalesced.rb +61 -0
  37. data/lib/mistri/sinks/sse.rb +26 -0
  38. data/lib/mistri/skill.rb +15 -0
  39. data/lib/mistri/skills.rb +81 -0
  40. data/lib/mistri/sse.rb +50 -0
  41. data/lib/mistri/stop_reason.rb +25 -0
  42. data/lib/mistri/stores/active_record.rb +47 -0
  43. data/lib/mistri/stores/jsonl.rb +37 -0
  44. data/lib/mistri/stores/memory.rb +22 -0
  45. data/lib/mistri/sub_agent.rb +211 -0
  46. data/lib/mistri/tool.rb +94 -0
  47. data/lib/mistri/tool_call.rb +18 -0
  48. data/lib/mistri/tool_context.rb +15 -0
  49. data/lib/mistri/tool_executor.rb +66 -0
  50. data/lib/mistri/tool_result.rb +23 -0
  51. data/lib/mistri/tools/edit_file.rb +37 -0
  52. data/lib/mistri/tools/find_in_file.rb +36 -0
  53. data/lib/mistri/tools/list_files.rb +16 -0
  54. data/lib/mistri/tools/read_file.rb +38 -0
  55. data/lib/mistri/tools/read_memory.rb +16 -0
  56. data/lib/mistri/tools/update_memory.rb +22 -0
  57. data/lib/mistri/tools/write_file.rb +20 -0
  58. data/lib/mistri/tools.rb +50 -0
  59. data/lib/mistri/transport.rb +187 -0
  60. data/lib/mistri/usage.rb +79 -0
  61. data/lib/mistri/version.rb +3 -1
  62. data/lib/mistri/workspace/active_record.rb +47 -0
  63. data/lib/mistri/workspace/directory.rb +52 -0
  64. data/lib/mistri/workspace/memory.rb +40 -0
  65. data/lib/mistri/workspace/single.rb +48 -0
  66. data/lib/mistri.rb +91 -2
  67. metadata +73 -7
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7ff0390f2958c74fb1e8a3f46f5ccf05b98e9f02811666cd624f34c80704efb2
4
- data.tar.gz: 9d7572d5ce220f9e7478f23dad4f1701d4dce080cbada222eac21788ae8e7e03
3
+ metadata.gz: 5b5526cf23888f4a5872797ce5a6ebe245c0ff1a857ba8bc717681930f8dd4b8
4
+ data.tar.gz: fa3f6dee2638cbd1e9c4b60378c40b4ac5a894d3639afff157be6bb70e5fd00f
5
5
  SHA512:
6
- metadata.gz: 86058bb5ec8de21a786f58158ff90c63fc06aaee36e6e517da0c9659ce167cb5f417a21a47ed0432713d9c3ce83508619d64ff0925c77368eaae3d2536cb5b44
7
- data.tar.gz: '068145c75adf083e1f5679387697cb904b5739b8ab96912b8e8135838010bbfe1c2376d003ff850c49f52e59bfa38ff986bdcaa3001330ab33c77e90780aa511'
6
+ metadata.gz: 8cb353d464264b7d44b8c335cfa938b72136f5273f674d4517632326929216a1a93db9577fb9c75ce66f46a70fc862726ad4444dad6e0a47ba0c8d74f3356ae5
7
+ data.tar.gz: e5114ba9c5404ee9698c4878599d9f2a7895e3d9c470e1874b0c377b2ac6b59ef62b541940c5798b5cb769df148f0e5a02d835b78bc75a5655905a2fd4bdfcdb
data/CHANGELOG.md ADDED
@@ -0,0 +1,177 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
+
6
+ ## [Unreleased]
7
+
8
+ ## [0.1.0] - 2026-07-05
9
+
10
+ - Live integration harness: `rake integration` runs every feature end to
11
+ end against real provider APIs, once per model in the matrix
12
+ (MISTRI_INTEGRATION_MODELS overrides the default trio). Scenarios assert
13
+ that generated codenames flowed through the machinery, so answers prove
14
+ information flow rather than model knowledge. The default `rake test`
15
+ stays hermetic.
16
+ - The spawn tool's child models now come only from a host allowlist
17
+ (`models:` on SubAgent.spawner); without one no model choice is offered,
18
+ so a hallucinated model id can never construct a provider. Found by the
19
+ integration harness on its first run.
20
+ - The skills system-prompt section instructs selection more firmly.
21
+
22
+ - Sub-agents: delegation with a clean context (#2). Mistri::SubAgent names
23
+ a curated specialist (own provider/system/tools, optional schema: for
24
+ validated JSON answers); SubAgent.spawner is the open spawn_agent tool
25
+ where the model writes the child's instructions, grants a tool subset,
26
+ and may pick a model. Children run fresh sessions on the caller's store,
27
+ linked in its transcript and on the tool result's ui channel; their
28
+ events stream into the parent tagged with origin. Parallel spawn calls
29
+ fan out on the executor pool. Approval-gated tools are refused inside
30
+ children; gate the delegation itself instead.
31
+ - Tool handlers now receive an optional second argument, ToolContext
32
+ (session, signal, emit); procs ignore it invisibly and strict lambdas
33
+ keep their old arity. Events gained an origin field.
34
+
35
+ - Task mode: Agent#task(input, schema:) runs an exchange that must end in
36
+ JSON matching the schema — tools run as usual, providers constrain the
37
+ final answer natively where supported (Anthropic output_config, OpenAI
38
+ text.format strict, Gemini responseJsonSchema when no tools), and the
39
+ answer validates client-side everywhere. A violation goes back to the
40
+ model once (fixes:), then raises SchemaError; Result#output carries the
41
+ validated value. run(output_schema:) exposes raw constrained runs.
42
+ - Schema.violations (a zero-dependency validator for the supported subset,
43
+ with model-feedable error messages) and Schema.strict (wire preparation:
44
+ additionalProperties false everywhere, all-required for OpenAI strict).
45
+
46
+ - Skills: Mistri::Skill and Skills.load (a directory of SKILL.md folders or
47
+ flat .md files, flat-frontmatter name/description). Pass skills: to the
48
+ Agent (array or path): descriptions join the system prompt and a
49
+ read_skill tool serves full bodies on demand, so a skill library costs
50
+ one line each until used. Hosts with skills in a database construct
51
+ Skill objects directly.
52
+
53
+ - Rails integration: `rails generate mistri:install YourModel` creates a
54
+ host-named entry model and its migration for Stores::ActiveRecord
55
+ (MEDIUMTEXT payload on MySQL-family adapters). Streaming sinks under
56
+ Mistri::Sinks — ActionCable (lazy server, injectable), SSE (outbound
57
+ frames to any IO), and Coalesced (merges delta bursts to UI speed) — all
58
+ pure Ruby, usable as `agent.run(input, &sink)`. No Railtie: generators
59
+ auto-discover and everything else duck-types.
60
+
61
+ - Retry policy: transient turn failures (429/5xx/529, timeouts, dropped or
62
+ truncated streams) retry with jittered backoff, honoring retry-after. On
63
+ by default (retries: on the Agent; false disables, or pass a tuned
64
+ RetryPolicy). Failed attempts record as retry entries and emit :retry
65
+ events but never become messages, so retries stay invisible to the model.
66
+ - Errored messages now carry a machine-readable error field ({type, status,
67
+ retry_after}) alongside error_message; in-stream provider errors keep
68
+ their wire classification (Anthropic overloaded, OpenAI rate limits,
69
+ Gemini status codes) instead of folding into prose.
70
+
71
+ - Two-channel tool results: a handler may return Mistri::ToolResult with
72
+ content for the model and ui for the host. The ui payload rides the tool
73
+ message and its :tool_result event, persists with the session for
74
+ transcript re-renders, and never reaches a provider.
75
+
76
+ - Context compaction: sessions compact automatically when the context grows
77
+ into the reserve headroom (compaction: on the Agent, on by default when the
78
+ model's window is known; pass false to disable). The provider writes a
79
+ visible structured summary; a compaction entry redirects replay to summary
80
+ plus kept tail while the full history stays in the store. Cuts land only on
81
+ user messages so tool pairs never split, parked approvals stay resumable,
82
+ and second compactions update the first summary. Agent#compact is the
83
+ manual button, Agent#context_usage the {tokens, window, fraction} gauge,
84
+ and :compacting/:compaction the events.
85
+
86
+ - transform_context: an Agent option that reshapes what the model sees each
87
+ turn (reminders, redaction, windowing) while the stored transcript stays
88
+ untouched. The lambda receives the replay messages and returns the messages
89
+ to send; it must keep tool calls paired with their results.
90
+
91
+ - Steering: Session#steer queues a user message from any process while a run
92
+ is live. The loop folds pending steers into the transcript at the next turn
93
+ boundary, and one that arrives as the model finishes cleanly extends the
94
+ run so it gets answered instead of dangling. Steers compose with approval
95
+ suspensions: queue a thought, approve, resume.
96
+
97
+ - Human-in-the-loop approval: a tool marked needs_approval (true or a
98
+ predicate on its arguments) suspends the run instead of executing. Runs
99
+ return a Result immediately; decisions are recorded on the session from any
100
+ process (approve/deny), and resume settles them and continues. No thread
101
+ ever waits on a human.
102
+ - Every run now returns a Mistri::Result (completed, awaiting_approval,
103
+ aborted, budget, or error) that delegates text and stop_reason to its final
104
+ message.
105
+ - Session entries are normalized to one canonical JSON shape across all
106
+ stores.
107
+
108
+ - Budget stops report stop_reason :budget, distinct from a user abort.
109
+ - Tool results that are arrays of data serialize as JSON; empty input and
110
+ duplicate tool names fail loudly at the boundary.
111
+
112
+ - `Mistri::Memory` and `Mistri::Tools.memory`: durable knowledge across
113
+ sessions, read and rewritten whole, living wherever the host points it.
114
+
115
+ - `Mistri::Workspace`: the document store agents work in, with memory,
116
+ directory, ActiveRecord, and single-document backends, so editing a
117
+ database column works exactly like editing a file.
118
+ - `Mistri::Tools.files`: the built-in document tools (read_file, write_file,
119
+ edit_file, find_in_file, list_files). The edit tool speaks the flat
120
+ old_string/new_string shape models are trained on, tolerates alias keys,
121
+ and reports misses with the closest region and its exact difference.
122
+ - `Mistri::Edit.replace`: single-edit replacement with replace_all, newline
123
+ and BOM preservation, and near-miss diagnostics.
124
+
125
+ - Truncated streams now fail as retryable errors on every provider instead of
126
+ reading as user cancellations, and their tool calls pair without executing.
127
+ - Provider error turns carry the HTTP status and response body.
128
+ - Budgets measure every ceiling per run, including wall clock.
129
+
130
+ - `Mistri::Edit`: pure fuzzy text replacement with a uniqueness guarantee,
131
+ so an edit never silently changes the wrong region; the string core for a
132
+ workspace-backed edit tool that works against a database as well as a file.
133
+
134
+ - `Mistri.agent` and `Mistri.provider`: build an agent or provider from a model
135
+ id, inferring the provider and reading its key from the environment.
136
+
137
+ - Aborted and truncated turns now replay without provider errors: unusable
138
+ thinking degrades to text, empty blocks are dropped, every tool call is
139
+ paired, and budget-only models skip adaptive thinking.
140
+
141
+ - The error hierarchy: every Mistri failure rescues as `Mistri::Error`.
142
+ - The message protocol: immutable content blocks (text, thinking, image, tool call),
143
+ messages with provider identity, usage accounting with cost math, stop reasons.
144
+ - The streaming event union: twelve event types, each carrying an immutable
145
+ partial-message snapshot.
146
+ - `Mistri::Providers::Fake`: a scriptable provider for hermetic host tests.
147
+ - `Mistri::AbortSignal`: a thread-safe cancel latch with abort callbacks.
148
+ - `Mistri::SSE`: an incremental server-sent-events decoder.
149
+ - `Mistri::Transport`: a persistent per-provider streaming connection with
150
+ status-mapped errors and hard abort of hung streams.
151
+ - `Mistri::PartialJson`: best-effort parsing of in-flight tool arguments.
152
+ - `Mistri::Models`: a capability catalog with graceful passthrough, so unknown
153
+ models work the day they ship.
154
+ - `Mistri::Providers::Anthropic`: the Messages API streamed, with adaptive
155
+ thinking, prompt caching, signature round-trips, and eager tool-input
156
+ streaming.
157
+ - `Mistri::Providers::OpenAI`: the Responses API streamed and stateless, with
158
+ encrypted reasoning replay and thinking summaries.
159
+ - `Mistri::Providers::Gemini`: generateContent streamed, with unconstrained
160
+ thinking by default and verbatim thought-signature replay.
161
+ - `Mistri::Agent`: the streaming tool-calling loop, persisting each turn as it
162
+ completes so aborts and crashes resume without repair.
163
+ - `Mistri::Tool` and `Mistri::Schema`: define tools with a raw JSON Schema or a
164
+ Ruby schema block; results may be text, JSON, or content blocks.
165
+ - `Mistri::Session` with pluggable stores (`Memory`, `JSONL`, and an optional
166
+ `ActiveRecord` adapter for the host's own database).
167
+ - `Mistri::Budget`: opt-in ceilings on turns, tokens, cost, and wall-clock;
168
+ nothing is enforced unless the host sets it.
169
+
170
+ ## [0.0.3] - 2026-07-04
171
+
172
+ - Repository moved to github.com/mcheemaa/mistri.
173
+ - Development toolchain: Minitest, RuboCop, CI.
174
+
175
+ ## [0.0.1] - 2026-07-04
176
+
177
+ - Reserved the gem name.
data/NOTICE ADDED
@@ -0,0 +1,9 @@
1
+ Mistri
2
+ Copyright (c) 2026 Muhammad Ahmed Cheema
3
+
4
+ Mistri's architecture is informed by pi (https://github.com/badlogic/pi-mono),
5
+ the agent harness by Mario Zechner, MIT licensed:
6
+ Copyright (c) 2025 Mario Zechner
7
+
8
+ Mistri also draws on lessons from truffle-rb, an earlier Ruby port of pi by the
9
+ same author as Mistri.
data/README.md CHANGED
@@ -1,5 +1,316 @@
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="mistri.gemspec"><img alt="Ruby >= 3.2" src="https://img.shields.io/badge/ruby-%3E%3D%203.2-CC342D"></a>
9
+ <a href="Gemfile"><img alt="Runtime dependencies: zero" src="https://img.shields.io/badge/runtime_deps-0-brightgreen"></a>
10
+ <a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-blue.svg"></a>
11
+ </p>
12
+
13
+ A mistri (Urdu: مستری) is the fixer: the skilled tradesperson who actually
14
+ gets it done. This one lives inside your app, not in a terminal. It runs the
15
+ model loop, executes tools, streams every event, persists sessions to your
16
+ own database, and pauses for a human when a tool needs approval, all with
17
+ zero runtime gem dependencies.
18
+
19
+ ```ruby
20
+ require "mistri"
21
+
22
+ weather = Mistri::Tool.define(
23
+ "get_weather", "Current weather for a city.",
24
+ schema: -> { string :city, "City name", required: true },
25
+ ) do |args|
26
+ Weather.for(args["city"])
27
+ end
28
+
29
+ agent = Mistri.agent("claude-opus-4-8", tools: [weather]) # reads ANTHROPIC_API_KEY
30
+
31
+ agent.run("What should I wear in Lahore today?") do |event|
32
+ print event.delta if event.type == :text_delta
33
+ end
34
+ ```
35
+
36
+ ## Why Mistri
37
+
38
+ - **Built for applications.** Sessions are durable, append-only records in
39
+ your own store. Runs stop, resume, steer, and compact from any process.
40
+ - **Fire-and-forget human approval.** A gated tool suspends the run and
41
+ returns immediately. The approval can arrive two days later from a bare
42
+ web request; nothing sleeps waiting.
43
+ - **Three providers, frontier-deep.** Anthropic, OpenAI, and Gemini, each
44
+ streamed natively with thinking, prompt caching, parallel tool calls, and
45
+ constrained JSON output. One message model across all three.
46
+ - **Zero runtime dependencies.** Plain Ruby all the way down.
47
+ - **Verified against real APIs.** A live integration harness runs every
48
+ feature end to end on every provider (`rake integration`).
49
+
50
+ ## Install
51
+
52
+ ```ruby
53
+ gem "mistri"
54
+ ```
55
+
56
+ ## Sixty-second start
57
+
58
+ ```ruby
59
+ agent = Mistri.agent("claude-opus-4-8")
60
+ result = agent.run("Name three Ruby web frameworks.")
61
+ puts result.text
62
+ ```
63
+
64
+ `Mistri.agent` infers the provider from the model id (`claude-*`, `gpt-*`,
65
+ `gemini-*`) and reads the matching key (`ANTHROPIC_API_KEY`,
66
+ `OPENAI_API_KEY`, `GEMINI_API_KEY`); pass `api_key:` to set it explicitly.
67
+ Every run returns a `Result`: `completed?`, `awaiting_approval?`,
68
+ `aborted?`, `errored?`, with `text` and (for tasks) `output`.
69
+
70
+ ## Tools
71
+
72
+ A tool is a name, a description, an argument schema, and a block. The block
73
+ returns a String, a Hash (sent as JSON), or content such as an image. The
74
+ agent calls tools, feeds results back, and loops until the model answers;
75
+ independent calls in a turn run in parallel.
76
+
77
+ ```ruby
78
+ weather = Mistri::Tool.define("get_weather", "Current weather for a city.", schema: lambda {
79
+ string :city, "City name", required: true
80
+ string :units, "Temperature units", enum: %w[celsius fahrenheit]
81
+ }) do |args|
82
+ Weather.for(args["city"], units: args["units"] || "celsius")
83
+ end
84
+ ```
85
+
86
+ A tool can speak on two channels: `content` for the model, `ui` for your
87
+ interface. The `ui` payload rides the `:tool_result` event and persists with
88
+ the session, but never reaches a provider:
89
+
90
+ ```ruby
91
+ Mistri::Tool.define("edit_page", "Applies a page edit.") do |args|
92
+ page = apply(args)
93
+ Mistri::ToolResult.new(content: "Saved.", ui: { "html" => page })
94
+ end
95
+ ```
96
+
97
+ ## Human approval
98
+
99
+ Mark a tool `needs_approval: true` (or a predicate on its arguments) and the
100
+ run suspends instead of executing it, instantly, with no thread waiting.
101
+ The decision is a one-line session write from any process, any time later;
102
+ `resume` settles it and carries on.
103
+
104
+ ```ruby
105
+ send_gift = Mistri::Tool.define("send_gift", "Sends a real gift.",
106
+ needs_approval: ->(args) { args["amount"].to_i > 100 }) do |args|
107
+ Gifts.send!(args)
108
+ end
109
+
110
+ result = agent.run("Send Ana a $200 gift")
111
+ result.awaiting_approval? # => true; nothing executed
112
+
113
+ # Days later, in a controller:
114
+ Mistri::Session.new(store:, id: session_id).approve(call_id) # or .deny(call_id, note: "...")
115
+
116
+ # Then, in a worker:
117
+ Mistri.agent("claude-opus-4-8", tools: tools, session: reloaded).resume
118
+ ```
119
+
120
+ The harness renders nothing: it emits an `:approval_needed` event and your
121
+ app draws the UI.
122
+
123
+ ## Steering
124
+
125
+ Queue a message into a running exchange from any process. It folds into the
126
+ conversation at the next turn boundary; one that arrives as the model
127
+ finishes cleanly extends the run so it gets answered.
128
+
129
+ ```ruby
130
+ Mistri::Session.new(store:, id: session_id).steer("Make the headline blue instead.")
131
+ ```
132
+
133
+ ## Sessions
134
+
135
+ A session is the durable record of a run: an append-only entry log over a
136
+ pluggable store (memory, JSONL files, or your database).
137
+
138
+ ```ruby
139
+ store = Mistri::Stores::JSONL.new("tmp/sessions")
140
+ session = Mistri::Session.new(store:)
141
+
142
+ agent = Mistri.agent("claude-opus-4-8", session:)
143
+ agent.run("Start a haiku about the sea.")
144
+
145
+ # Later, even in another process: reload by id and continue.
146
+ resumed = Mistri.agent("claude-opus-4-8", session: Mistri::Session.new(store:, id: session.id))
147
+ resumed.run("Now finish it.")
148
+ ```
149
+
150
+ In Rails, generate a model (name it whatever you like) and use the
151
+ ActiveRecord store:
152
+
153
+ ```console
154
+ $ bin/rails generate mistri:install AgentEntry
155
+ ```
156
+
157
+ ```ruby
158
+ require "mistri/stores/active_record"
159
+ store = Mistri::Stores::ActiveRecord.new(AgentEntry)
160
+ ```
161
+
162
+ ## Compaction
163
+
164
+ Long sessions survive their context window: when the conversation grows into
165
+ the reserve headroom, the provider writes a visible structured summary and
166
+ replay continues from it. The full history stays in your store for
167
+ transcript views. On by default whenever the model's window is known;
168
+ `compaction: false` disables it.
169
+
170
+ ```ruby
171
+ agent.context_usage # => { tokens: 141_000, window: 200_000, fraction: 0.705 }
172
+ agent.compact # the manual button
173
+ ```
174
+
175
+ `:compacting` and `:compaction` events carry the summary, so users see
176
+ exactly what the model still remembers.
177
+
178
+ ## Task mode
179
+
180
+ A run that must end in JSON matching a schema. Tools run as usual; providers
181
+ constrain the final answer natively where they can, and the answer is
182
+ validated client-side everywhere. A violation goes back to the model once,
183
+ then raises. You get a guaranteed shape or a loud error, never silence.
184
+
185
+ ```ruby
186
+ schema = {
187
+ type: "object",
188
+ properties: { "tiers" => { type: "array", items: { type: "string" } } },
189
+ required: ["tiers"],
190
+ }
191
+
192
+ result = agent.task("Extract the pricing tiers from this page.", schema: schema)
193
+ result.output # => { "tiers" => [...] }, parsed and validated
194
+ ```
195
+
196
+ ## Skills
197
+
198
+ Expert playbooks with progressive disclosure: each skill costs one line in
199
+ the system prompt until the model decides it is relevant and pulls the full
200
+ body through an auto-provided `read_skill` tool.
201
+
202
+ ```ruby
203
+ agent = Mistri.agent("claude-opus-4-8", skills: "app/skills") # or an array of Mistri::Skill
204
+ ```
205
+
206
+ A skill is a `SKILL.md` (or flat `.md`) with `name:`/`description:`
207
+ frontmatter, or built from database rows with
208
+ `Mistri::Skill.new(name:, description:, body:)`.
209
+
210
+ ## Sub-agents
211
+
212
+ Delegate to a child agent with a clean context: exploration fills the
213
+ child's window, and only the final answer returns. Children run on their own
214
+ sessions in your store, linked in the parent transcript; their events stream
215
+ into the parent tagged with an `origin`.
216
+
217
+ ```ruby
218
+ researcher = Mistri::SubAgent.new(
219
+ name: "researcher", description: "Reads pages and answers factual questions.",
220
+ provider: Mistri.provider("claude-haiku-4-5-20251001"), # cheaper model for grunt work
221
+ system: "Research. Report findings only.", tools: [fetch_page],
222
+ )
223
+ agent = Mistri.agent("claude-opus-4-8", tools: [researcher.tool])
224
+ ```
225
+
226
+ Or hand the model an open spawn tool and let it compose its own workers:
227
+ instructions, a tool subset, and a host-allowlisted model per child.
228
+ Several spawns in one turn fan out in parallel:
229
+
230
+ ```ruby
231
+ spawn = Mistri::SubAgent.spawner(provider: provider, tools: [fetch_page, search])
232
+ ```
233
+
234
+ ## Editing documents
235
+
236
+ The document tools (`read_file`, `edit_file`, `write_file`, `find_in_file`,
237
+ `list_files`) work over a workspace: a directory, memory, ActiveRecord, or
238
+ a single value anywhere, like one database column holding a page:
239
+
240
+ ```ruby
241
+ workspace = Mistri::Workspace::Single.new(
242
+ read: -> { page.html },
243
+ write: ->(html) { page.update!(html: html) },
244
+ path: "hero.html",
245
+ )
246
+ agent = Mistri.agent("claude-opus-4-8", tools: Mistri::Tools.files(workspace))
247
+ ```
248
+
249
+ The edit engine matches exactly, then whitespace-tolerantly; an ambiguous
250
+ match refuses (never silently edits the wrong place), and a near-miss error
251
+ names the closest region so the model's retry is one-shot.
252
+
253
+ ## Streaming into Rails
254
+
255
+ Sinks bridge the event stream to a transport, and compose as blocks:
256
+
257
+ ```ruby
258
+ cable = Mistri::Sinks::ActionCable.new("agent_#{session.id}")
259
+ sink = Mistri::Sinks::Coalesced.new(cable) # merges token bursts to UI speed
260
+
261
+ agent.run(input, &sink)
262
+ ```
263
+
264
+ `Mistri::Sinks::SSE.new(response.stream)` does the same for
265
+ `ActionController::Live`. There is no Railtie and nothing to configure;
266
+ the generator and stores duck-type into any app.
267
+
268
+ ## Stopping, budgets, reliability
269
+
270
+ ```ruby
271
+ # Trip the signal from anywhere; the partial turn persists, resume is clean.
272
+ signal = Mistri::AbortSignal.new
273
+ agent.run("Draft a long essay.", signal: signal)
274
+
275
+ # Ceilings are opt-in and off by default.
276
+ budget = Mistri::Budget.new(turns: 20, cost_usd: 2.00)
277
+
278
+ # Transient failures (429, 5xx, timeouts) retry with backoff, invisibly to
279
+ # the model. On by default; retries: false disables.
280
+ policy = Mistri::RetryPolicy.new(attempts: 3)
281
+ ```
282
+
283
+ ## Images and provider options
284
+
285
+ ```ruby
286
+ photo = Mistri::Content::Image.from_bytes(File.binread("chart.png"), mime_type: "image/png")
287
+ agent.run("What trend does this chart show?", images: [photo])
288
+
289
+ Mistri.agent("gpt-5.5", provider_options: { reasoning: { effort: "high" } })
290
+ Mistri.agent("claude-opus-4-8", provider_options: { cache: false })
291
+ ```
292
+
293
+ ## Verified for real
294
+
295
+ `rake test` is hermetic and fast. `rake integration` runs every feature end
296
+ to end against real provider APIs, once per model in the matrix. Scenarios
297
+ assert that coined codenames (a ghost of a word like `Wraithowyn` exists in
298
+ no training data) flowed through tool results, summaries, and child agents:
299
+ proof of information flow, not model knowledge.
300
+
301
+ ```console
302
+ $ MISTRI_INTEGRATION_MODELS=claude-opus-4-8,gpt-5.5 bundle exec rake integration
303
+ ```
304
+
305
+ ## Roadmap
306
+
307
+ `0.2.0`: an MCP client bridge, so any MCP server's tools plug into an agent.
308
+
309
+ ## Credits
310
+
311
+ Mistri's architecture is informed by [pi](https://github.com/badlogic/pi-mono)
312
+ by Mario Zechner. See NOTICE.
313
+
314
+ ## License
315
+
316
+ 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
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ # A thread-safe, one-way latch for cancelling a run. The host trips it from
5
+ # any thread; the loop and tools check it cooperatively at safe points, and
6
+ # the transport registers an on-abort callback to close an in-flight socket,
7
+ # so even a stalled read stops immediately instead of waiting out its
8
+ # read timeout.
9
+ class AbortSignal
10
+ def initialize
11
+ @mutex = Mutex.new
12
+ @aborted = false
13
+ @reason = nil
14
+ @callbacks = []
15
+ end
16
+
17
+ def aborted? = @aborted
18
+
19
+ attr_reader :reason
20
+
21
+ # Trip the latch and fire every registered callback exactly once.
22
+ # Subsequent calls are no-ops.
23
+ def abort!(reason = nil)
24
+ callbacks = @mutex.synchronize do
25
+ break [] if @aborted
26
+
27
+ @aborted = true
28
+ @reason = reason
29
+ @callbacks.dup.tap { @callbacks.clear }
30
+ end
31
+ callbacks.each { |callback| safely(callback) }
32
+ nil
33
+ end
34
+
35
+ # Register a callback for the moment of abort. Fires immediately when the
36
+ # signal is already tripped. Returns a handle for #remove_callback.
37
+ def on_abort(&callback)
38
+ fire_now = @mutex.synchronize do
39
+ @callbacks << callback unless @aborted
40
+ @aborted
41
+ end
42
+ safely(callback) if fire_now
43
+ callback
44
+ end
45
+
46
+ # Deregister a callback, so a completed request does not leak its socket
47
+ # closer into a later abort.
48
+ def remove_callback(handle)
49
+ @mutex.synchronize { @callbacks.delete(handle) }
50
+ nil
51
+ end
52
+
53
+ private
54
+
55
+ # An abort must reach every callback; one raising observer cannot be
56
+ # allowed to strand the others.
57
+ def safely(callback)
58
+ callback.call
59
+ rescue StandardError
60
+ nil
61
+ end
62
+ end
63
+ end