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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +215 -0
- data/README.md +367 -3
- data/lib/generators/mistri/install/install_generator.rb +54 -0
- data/lib/generators/mistri/install/templates/migration.rb.tt +14 -0
- data/lib/generators/mistri/install/templates/model.rb.tt +4 -0
- data/lib/generators/mistri/mcp/mcp_generator.rb +57 -0
- data/lib/generators/mistri/mcp/templates/migration.rb.tt +27 -0
- data/lib/generators/mistri/mcp/templates/model.rb.tt +63 -0
- data/lib/mistri/abort_signal.rb +63 -0
- data/lib/mistri/agent.rb +389 -0
- data/lib/mistri/budget.rb +29 -0
- data/lib/mistri/compaction.rb +78 -0
- data/lib/mistri/compactor.rb +182 -0
- data/lib/mistri/content.rb +89 -0
- data/lib/mistri/edit.rb +238 -0
- data/lib/mistri/errors.rb +94 -0
- data/lib/mistri/event.rb +54 -0
- data/lib/mistri/mcp/client.rb +156 -0
- data/lib/mistri/mcp/oauth.rb +286 -0
- data/lib/mistri/mcp/wires.rb +164 -0
- data/lib/mistri/mcp.rb +96 -0
- data/lib/mistri/memory.rb +26 -0
- data/lib/mistri/message.rb +90 -0
- data/lib/mistri/models.rb +43 -0
- data/lib/mistri/partial_json.rb +210 -0
- data/lib/mistri/providers/anthropic/assembler.rb +205 -0
- data/lib/mistri/providers/anthropic/serializer.rb +106 -0
- data/lib/mistri/providers/anthropic.rb +106 -0
- data/lib/mistri/providers/fake.rb +109 -0
- data/lib/mistri/providers/gemini/assembler.rb +163 -0
- data/lib/mistri/providers/gemini/serializer.rb +109 -0
- data/lib/mistri/providers/gemini.rb +73 -0
- data/lib/mistri/providers/openai/assembler.rb +205 -0
- data/lib/mistri/providers/openai/serializer.rb +104 -0
- data/lib/mistri/providers/openai.rb +72 -0
- data/lib/mistri/reminder.rb +36 -0
- data/lib/mistri/result.rb +32 -0
- data/lib/mistri/retry_policy.rb +47 -0
- data/lib/mistri/schema.rb +162 -0
- data/lib/mistri/session.rb +124 -0
- data/lib/mistri/sinks/action_cable.rb +30 -0
- data/lib/mistri/sinks/coalesced.rb +61 -0
- data/lib/mistri/sinks/sse.rb +26 -0
- data/lib/mistri/skill.rb +15 -0
- data/lib/mistri/skills.rb +81 -0
- data/lib/mistri/sse.rb +50 -0
- data/lib/mistri/stop_reason.rb +25 -0
- data/lib/mistri/stores/active_record.rb +47 -0
- data/lib/mistri/stores/jsonl.rb +37 -0
- data/lib/mistri/stores/memory.rb +22 -0
- data/lib/mistri/sub_agent.rb +211 -0
- data/lib/mistri/tool.rb +95 -0
- data/lib/mistri/tool_call.rb +18 -0
- data/lib/mistri/tool_context.rb +15 -0
- data/lib/mistri/tool_executor.rb +87 -0
- data/lib/mistri/tool_result.rb +23 -0
- data/lib/mistri/tools/edit_file.rb +37 -0
- data/lib/mistri/tools/find_in_file.rb +36 -0
- data/lib/mistri/tools/list_files.rb +16 -0
- data/lib/mistri/tools/read_file.rb +38 -0
- data/lib/mistri/tools/read_memory.rb +16 -0
- data/lib/mistri/tools/update_memory.rb +22 -0
- data/lib/mistri/tools/write_file.rb +20 -0
- data/lib/mistri/tools.rb +50 -0
- data/lib/mistri/transport.rb +228 -0
- data/lib/mistri/usage.rb +79 -0
- data/lib/mistri/version.rb +1 -1
- data/lib/mistri/workspace/active_record.rb +47 -0
- data/lib/mistri/workspace/directory.rb +52 -0
- data/lib/mistri/workspace/memory.rb +40 -0
- data/lib/mistri/workspace/single.rb +48 -0
- data/lib/mistri.rb +89 -0
- metadata +79 -10
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 53547d685a896819e552e9911249b042715686f47b1c61a3e01ca2a9542f915d
|
|
4
|
+
data.tar.gz: d902f802d0272fe0ba99525946ee50169c660d43c99d33790061231854cae302
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
1
|
+
<h1 align="center">مستری</h1>
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<p align="center"><strong>mistri</strong>, the agent harness for Ruby applications.</p>
|
|
4
4
|
|
|
5
|
-
|
|
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
|