mistri 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +469 -4
- data/CONTRIBUTING.md +52 -0
- data/README.md +289 -385
- data/SECURITY.md +40 -0
- data/UPGRADING.md +640 -0
- data/assets/logo-animated.svg +30 -0
- data/assets/logo-dark.svg +14 -0
- data/assets/logo-light.svg +14 -0
- data/assets/logo.svg +14 -0
- data/assets/social-preview.png +0 -0
- data/docs/README.md +87 -0
- data/docs/context-and-workspaces.md +378 -0
- data/docs/mcp.md +366 -0
- data/docs/reliability.md +450 -0
- data/docs/sessions.md +295 -0
- data/docs/sub-agents.md +401 -0
- data/docs/tool-contracts.md +324 -0
- data/examples/approval.rb +36 -0
- data/examples/browser.rb +27 -0
- data/examples/page_editor.rb +31 -0
- data/examples/quickstart.rb +21 -0
- data/lib/generators/mistri/install/install_generator.rb +7 -3
- data/lib/generators/mistri/install/templates/migration.rb.tt +2 -2
- data/lib/generators/mistri/mcp/templates/migration.rb.tt +1 -1
- data/lib/generators/mistri/mcp/templates/model.rb.tt +15 -8
- data/lib/mistri/agent.rb +575 -55
- data/lib/mistri/budget.rb +26 -1
- data/lib/mistri/child.rb +72 -16
- data/lib/mistri/compaction.rb +26 -10
- data/lib/mistri/compactor.rb +34 -11
- data/lib/mistri/console.rb +28 -7
- data/lib/mistri/content.rb +9 -3
- data/lib/mistri/dispatchers.rb +14 -12
- data/lib/mistri/errors.rb +83 -4
- data/lib/mistri/event.rb +24 -8
- data/lib/mistri/event_delivery.rb +60 -0
- data/lib/mistri/locks.rb +3 -3
- data/lib/mistri/mcp/client.rb +74 -19
- data/lib/mistri/mcp/egress.rb +216 -0
- data/lib/mistri/mcp/oauth.rb +476 -127
- data/lib/mistri/mcp/wires.rb +115 -23
- data/lib/mistri/mcp.rb +42 -8
- data/lib/mistri/message.rb +21 -11
- data/lib/mistri/models.rb +160 -22
- data/lib/mistri/providers/anthropic/assembler.rb +282 -44
- data/lib/mistri/providers/anthropic/serializer.rb +14 -9
- data/lib/mistri/providers/anthropic.rb +29 -6
- data/lib/mistri/providers/fake.rb +26 -10
- data/lib/mistri/providers/gemini/assembler.rb +148 -21
- data/lib/mistri/providers/gemini/serializer.rb +78 -9
- data/lib/mistri/providers/gemini.rb +31 -5
- data/lib/mistri/providers/openai/assembler.rb +337 -60
- data/lib/mistri/providers/openai/serializer.rb +13 -12
- data/lib/mistri/providers/openai.rb +29 -5
- data/lib/mistri/providers/schema_capabilities.rb +214 -0
- data/lib/mistri/result.rb +1 -1
- data/lib/mistri/schema.rb +893 -75
- data/lib/mistri/session.rb +560 -48
- data/lib/mistri/sinks/coalesced.rb +17 -10
- data/lib/mistri/spawner.rb +111 -61
- data/lib/mistri/sse.rb +57 -14
- data/lib/mistri/stores/active_record.rb +1 -1
- data/lib/mistri/stores/memory.rb +21 -2
- data/lib/mistri/sub_agent/execution.rb +81 -0
- data/lib/mistri/sub_agent/runtime.rb +297 -0
- data/lib/mistri/sub_agent.rb +124 -87
- data/lib/mistri/task_output.rb +24 -6
- data/lib/mistri/tool.rb +93 -13
- data/lib/mistri/tool_arguments.rb +377 -0
- data/lib/mistri/tool_call.rb +43 -9
- data/lib/mistri/tool_context.rb +4 -2
- data/lib/mistri/tool_executor.rb +117 -26
- data/lib/mistri/tool_result.rb +15 -10
- data/lib/mistri/tools/edit_file.rb +62 -8
- data/lib/mistri/tools.rb +41 -4
- data/lib/mistri/transport.rb +149 -44
- data/lib/mistri/usage.rb +65 -13
- data/lib/mistri/version.rb +1 -1
- data/lib/mistri/workspace/active_record.rb +183 -3
- data/lib/mistri/workspace/directory.rb +28 -8
- data/lib/mistri/workspace/memory.rb +34 -9
- data/lib/mistri/workspace/single.rb +62 -5
- data/lib/mistri/workspace.rb +39 -0
- data/lib/mistri.rb +6 -1
- data/mistri.gemspec +34 -0
- metadata +31 -3
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
# Tool contracts
|
|
2
|
+
|
|
3
|
+
A Mistri tool is application code exposed to model output. Treat that boundary
|
|
4
|
+
like an HTTP endpoint: declare what it accepts, authorize the acting context,
|
|
5
|
+
and make writes idempotent or reconcilable.
|
|
6
|
+
|
|
7
|
+
This guide covers local tools. Remote MCP tools cross the same execution
|
|
8
|
+
boundary; see [MCP](mcp.md) for the additional server contract.
|
|
9
|
+
|
|
10
|
+
## Define a tool
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
charge_card = Mistri::Tool.define(
|
|
14
|
+
"charge_card", "Charges a saved payment method.",
|
|
15
|
+
schema: lambda {
|
|
16
|
+
string :payment_method_id, "Saved payment method ID", required: true
|
|
17
|
+
number :amount_usd, "Amount in US dollars", required: true
|
|
18
|
+
},
|
|
19
|
+
needs_approval: ->(args) { args.fetch("amount_usd") >= 500 },
|
|
20
|
+
) do |args, context|
|
|
21
|
+
Payments.charge!(
|
|
22
|
+
customer: context.app.fetch(:customer),
|
|
23
|
+
payment_method_id: args.fetch("payment_method_id"),
|
|
24
|
+
amount_usd: args.fetch("amount_usd"),
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The handler may accept only `args`, or `args` and `context`. `context.app` is
|
|
30
|
+
the object passed as `Mistri.agent(context:)`; it is the usual place for the
|
|
31
|
+
acting user, tenant, or request-scoped authorization context.
|
|
32
|
+
|
|
33
|
+
Handlers may return:
|
|
34
|
+
|
|
35
|
+
- a String, delivered to the model as text;
|
|
36
|
+
- a Hash, number, boolean, nil, or data Array, serialized as JSON or empty text;
|
|
37
|
+
- an Array made only of Strings and Mistri content blocks, delivered as
|
|
38
|
+
multiple content blocks, including images;
|
|
39
|
+
- `Mistri::ToolResult`, which separates model content, host-only UI, and an
|
|
40
|
+
explicit failure fact.
|
|
41
|
+
|
|
42
|
+
## The execution boundary
|
|
43
|
+
|
|
44
|
+
A completed model call moves through these phases in order:
|
|
45
|
+
|
|
46
|
+
1. Mistri verifies the provider's call envelope and pairing metadata.
|
|
47
|
+
2. The argument value is copied into bounded, immutable JSON owned by Mistri.
|
|
48
|
+
3. The tool's explicit `argument_normalizer`, if any, runs once.
|
|
49
|
+
4. Core schema validation and the tool's host validator run.
|
|
50
|
+
5. `before_tool` evaluates current application policy.
|
|
51
|
+
6. `needs_approval` decides whether the prepared call must park.
|
|
52
|
+
7. A free call emits `:tool_started` and invokes the handler.
|
|
53
|
+
8. `after_tool` may rewrite the result, which then persists and emits
|
|
54
|
+
`:tool_result`.
|
|
55
|
+
|
|
56
|
+
Malformed or invalid calls do not reach policy or execution. The model receives
|
|
57
|
+
a bounded error result it can correct, while valid sibling calls in the same
|
|
58
|
+
turn continue. Results settle in the model's call order even when handlers run
|
|
59
|
+
concurrently.
|
|
60
|
+
|
|
61
|
+
An approved call is revalidated against the current tool definition and passes
|
|
62
|
+
through `before_tool` again on `resume`. Its normalizer and approval predicate
|
|
63
|
+
do not run again: the human approved the already prepared arguments.
|
|
64
|
+
|
|
65
|
+
## Schema forms
|
|
66
|
+
|
|
67
|
+
The DSL builds a JSON Schema 2020-12 object:
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
search = Mistri::Tool.define("search", "Searches products.", schema: lambda {
|
|
71
|
+
string :query, "Search text", required: true
|
|
72
|
+
integer :limit, "Maximum result count"
|
|
73
|
+
array :tags, "Required tags", items: { type: "string" }
|
|
74
|
+
object :filters, "Structured filters" do
|
|
75
|
+
string :country, "ISO country code"
|
|
76
|
+
boolean :in_stock, "Only products in stock"
|
|
77
|
+
end
|
|
78
|
+
}) do |args|
|
|
79
|
+
filters = args.fetch("filters", {})
|
|
80
|
+
Catalog.search(
|
|
81
|
+
query: args.fetch("query"),
|
|
82
|
+
limit: args["limit"],
|
|
83
|
+
tags: args.fetch("tags", []),
|
|
84
|
+
country: filters["country"],
|
|
85
|
+
in_stock: filters["in_stock"],
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Use `input_schema:` for a raw schema:
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
schema = {
|
|
94
|
+
type: "object",
|
|
95
|
+
properties: {
|
|
96
|
+
"invoice_id" => { type: "string" },
|
|
97
|
+
},
|
|
98
|
+
required: ["invoice_id"],
|
|
99
|
+
additionalProperties: false,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
Mistri::Tool.define("pay_invoice", "Pays one invoice.", input_schema: schema) do |args|
|
|
103
|
+
Invoices.pay!(args.fetch("invoice_id"))
|
|
104
|
+
end
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The root must be an object schema. `Tool.define` without `schema:` or
|
|
108
|
+
`input_schema:` creates a closed no-argument tool; model-supplied fields are
|
|
109
|
+
rejected.
|
|
110
|
+
|
|
111
|
+
### Open and closed objects
|
|
112
|
+
|
|
113
|
+
Supplied object schemas follow JSON Schema's default-open semantics. Declared
|
|
114
|
+
properties do not automatically reject additional keys. Extract the fields you
|
|
115
|
+
intend to use rather than mass-assigning the entire model hash.
|
|
116
|
+
|
|
117
|
+
Set `additionalProperties: false` in a raw schema when extra keys must fail.
|
|
118
|
+
|
|
119
|
+
A nested object declared without a block is intentionally freeform:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
object :config, "Chart-library configuration", required: true
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
It accepts any JSON object, including `{}`. This is useful when Mistri should
|
|
126
|
+
not prescribe keys owned by another library. A bare top-level `Schema.build`
|
|
127
|
+
raises because it communicates no argument contract.
|
|
128
|
+
|
|
129
|
+
Freeform objects cannot become strict constrained output without changing their
|
|
130
|
+
meaning. `Schema.strict` and task planning reject that shape instead of silently
|
|
131
|
+
narrowing it to `{}`.
|
|
132
|
+
|
|
133
|
+
### Portable validation subset
|
|
134
|
+
|
|
135
|
+
Mistri's zero-dependency validator enforces:
|
|
136
|
+
|
|
137
|
+
- JSON `type`, including type unions;
|
|
138
|
+
- `enum`;
|
|
139
|
+
- `required` and declared `properties`;
|
|
140
|
+
- one-schema array `items`;
|
|
141
|
+
- tuple array `prefixItems`;
|
|
142
|
+
- boolean schemas;
|
|
143
|
+
- boolean or schema-valued `additionalProperties`.
|
|
144
|
+
|
|
145
|
+
Other standard keywords remain provider-facing generation guidance unless a
|
|
146
|
+
complete validator is supplied. Examples include `minimum`, `pattern`,
|
|
147
|
+
`format`, length constraints, and applicators such as `anyOf`.
|
|
148
|
+
|
|
149
|
+
An argument-applicable, non-empty `patternProperties` requires complete
|
|
150
|
+
authority because approximating its key and value interactions would be
|
|
151
|
+
unsafe. External references are not resolved.
|
|
152
|
+
|
|
153
|
+
Task output is stricter than tool input: task planning rejects assertions that
|
|
154
|
+
local validation cannot guarantee. A task promises a validated value; a tool
|
|
155
|
+
server can still enforce its own broader contract after Mistri's local subset.
|
|
156
|
+
|
|
157
|
+
## Host validation
|
|
158
|
+
|
|
159
|
+
Use `argument_validator:` for domain rules that supplement core validation:
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
invoice = Mistri::Tool.define(
|
|
163
|
+
"pay_invoice", "Pays an approved invoice.",
|
|
164
|
+
input_schema: invoice_schema,
|
|
165
|
+
argument_validator: lambda { |args, _schema|
|
|
166
|
+
if args.fetch("invoice_id").start_with?("inv_")
|
|
167
|
+
[]
|
|
168
|
+
else
|
|
169
|
+
["$.invoice_id must identify an invoice"]
|
|
170
|
+
end
|
|
171
|
+
},
|
|
172
|
+
) do |args|
|
|
173
|
+
Invoices.pay!(args.fetch("invoice_id"))
|
|
174
|
+
end
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
The validator receives the deeply frozen arguments and canonical schema. It
|
|
178
|
+
must return an Array of Strings. Core validation runs first and cannot be
|
|
179
|
+
weakened.
|
|
180
|
+
|
|
181
|
+
Use `complete_argument_validator:` only when the host validator implements the
|
|
182
|
+
whole schema, including interactions outside Mistri's portable subset. It has
|
|
183
|
+
the same `(args, schema)` signature and is mutually exclusive with
|
|
184
|
+
`argument_validator:`.
|
|
185
|
+
|
|
186
|
+
Validator errors are sent back to the model. Describe the expected contract;
|
|
187
|
+
do not echo received secrets or field values.
|
|
188
|
+
|
|
189
|
+
## Explicit normalization
|
|
190
|
+
|
|
191
|
+
Mistri does not coerce model input. If a tool intentionally accepts a legacy
|
|
192
|
+
alias or representation, declare that compatibility policy on the tool:
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
normalizer = lambda do |args|
|
|
196
|
+
next args unless args.key?("account")
|
|
197
|
+
raise ArgumentError, "choose account or account_id" if args.key?("account_id")
|
|
198
|
+
|
|
199
|
+
normalized = args.dup
|
|
200
|
+
normalized["account_id"] = normalized.delete("account")
|
|
201
|
+
normalized
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
tool = Mistri::Tool.define(
|
|
205
|
+
"lookup_account", "Looks up one account.",
|
|
206
|
+
schema: -> { string :account_id, "Account ID", required: true },
|
|
207
|
+
argument_normalizer: normalizer,
|
|
208
|
+
) do |args|
|
|
209
|
+
Accounts.find(args.fetch("account_id"))
|
|
210
|
+
end
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
The normalizer must return a Hash and should be pure, deterministic, and
|
|
214
|
+
idempotent. Its output is what validation, policy, approval, and the handler
|
|
215
|
+
see.
|
|
216
|
+
|
|
217
|
+
Direct `Tool#call` is a trusted host invocation. It applies the normalizer for
|
|
218
|
+
compatibility, but the Agent's model-input ownership and validation boundary is
|
|
219
|
+
not involved.
|
|
220
|
+
|
|
221
|
+
## Policy hooks
|
|
222
|
+
|
|
223
|
+
`before_tool` can block a call with a model-readable reason:
|
|
224
|
+
|
|
225
|
+
```ruby
|
|
226
|
+
before_tool = lambda do |call, context|
|
|
227
|
+
next "customer cannot perform this action" unless context.app.fetch(:customer).active?
|
|
228
|
+
next "currency is not enabled" unless call.arguments.fetch("currency") == "USD"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
agent = Mistri.agent("claude-opus-4-8", tools: tools, before_tool: before_tool)
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
The hook also runs when an approved call resumes, so long-lived approval does
|
|
235
|
+
not bypass current authorization. A raised hook error blocks conservatively.
|
|
236
|
+
|
|
237
|
+
`after_tool` receives `(call, result, context)` and may return a replacement
|
|
238
|
+
result. Returning nil keeps the original. If the original result is an error,
|
|
239
|
+
rewriting its text cannot relabel it as success.
|
|
240
|
+
|
|
241
|
+
## Results and UI
|
|
242
|
+
|
|
243
|
+
Use `Mistri::ToolResult` when the model and host need different views:
|
|
244
|
+
|
|
245
|
+
```ruby
|
|
246
|
+
Mistri::Tool.define("edit_page", "Applies a page edit.", schema: lambda {
|
|
247
|
+
object :changes, "Page changes", required: true
|
|
248
|
+
}) do |args|
|
|
249
|
+
page = Pages.apply(args.fetch("changes"))
|
|
250
|
+
Mistri::ToolResult.new(
|
|
251
|
+
content: "Saved the page.",
|
|
252
|
+
ui: { "html" => page.html, "revision" => page.lock_version },
|
|
253
|
+
)
|
|
254
|
+
end
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
`ui` persists on the tool message and appears on the `:tool_result` event, but
|
|
258
|
+
is never sent to a provider.
|
|
259
|
+
|
|
260
|
+
Non-text tool-result content remains provider-neutral in the Session. Anthropic
|
|
261
|
+
can receive image blocks in a tool result. The current OpenAI and Gemini
|
|
262
|
+
function-result serializers have no such encoding and send an explicit
|
|
263
|
+
non-text-omission marker alongside the text instead.
|
|
264
|
+
|
|
265
|
+
Return `error: true` for an expected failure the model should handle:
|
|
266
|
+
|
|
267
|
+
```ruby
|
|
268
|
+
account || Mistri::ToolResult.new(
|
|
269
|
+
content: "Account not found.",
|
|
270
|
+
error: true,
|
|
271
|
+
)
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Mistri also marks unknown tools, validation and policy rejection, handler
|
|
275
|
+
exceptions, timeouts, pre-invocation interruption, failed result hooks,
|
|
276
|
+
crash-healed calls, and MCP `isError` results. Human denial is an expected
|
|
277
|
+
control outcome, not an execution failure.
|
|
278
|
+
|
|
279
|
+
`event.tool_error` and `event.message.tool_error?` expose the fact without
|
|
280
|
+
parsing prose. A legacy persisted result without the field remains unknown;
|
|
281
|
+
Mistri does not infer historical success from text.
|
|
282
|
+
|
|
283
|
+
An error flag does not make a replay safe. A handler may have committed a write
|
|
284
|
+
before raising or timing out. Mistri never mechanically replays the same call
|
|
285
|
+
because it is marked failed, but the model may issue another call. The host
|
|
286
|
+
still owns idempotency and reconciliation.
|
|
287
|
+
|
|
288
|
+
## Hand off the turn
|
|
289
|
+
|
|
290
|
+
`ends_turn: true` ends the run after the tool executes instead of asking the
|
|
291
|
+
model for another response. This is useful for `ask_user`, escalation, or a
|
|
292
|
+
structural handoff:
|
|
293
|
+
|
|
294
|
+
```ruby
|
|
295
|
+
ask_user = Mistri::Tool.define(
|
|
296
|
+
"ask_user", "Asks the human and waits.",
|
|
297
|
+
ends_turn: true,
|
|
298
|
+
schema: -> { string :question, "Question", required: true },
|
|
299
|
+
) do |_args|
|
|
300
|
+
"Question presented to the user."
|
|
301
|
+
end
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
`result.handed_off?` reports that the model's floor moved away. It does not
|
|
305
|
+
claim the tool succeeded; inspect the tool result separately. The human answer
|
|
306
|
+
arrives as the next `run` input.
|
|
307
|
+
|
|
308
|
+
## Resource limits
|
|
309
|
+
|
|
310
|
+
Completed arguments are bounded at 8 MiB, 64 levels, 10,000 JSON nodes, and 64
|
|
311
|
+
KiB per numeric token. Encoded Anthropic and OpenAI arguments receive a linear
|
|
312
|
+
lexical pass before JSON parsing. Gemini's enclosing SSE record receives the
|
|
313
|
+
same structural preflight before its argument object is canonicalized.
|
|
314
|
+
|
|
315
|
+
These are safety boundaries, not business validation. Keep tool schemas narrow,
|
|
316
|
+
validate domain rules explicitly, and return references rather than embedding
|
|
317
|
+
unbounded data in a tool call.
|
|
318
|
+
|
|
319
|
+
## Related guides
|
|
320
|
+
|
|
321
|
+
- [Sessions and control](sessions.md)
|
|
322
|
+
- [MCP](mcp.md)
|
|
323
|
+
- [Reliability](reliability.md)
|
|
324
|
+
- [Upgrading](../UPGRADING.md)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Fire-and-forget human approval. The run suspends without executing the
|
|
4
|
+
# gated tool; the decision is a session write (here immediate, in production
|
|
5
|
+
# a controller action days later); resume settles it and finishes. Sessions
|
|
6
|
+
# live in JSONL files so every step could happen in a different process.
|
|
7
|
+
# Needs ANTHROPIC_API_KEY.
|
|
8
|
+
#
|
|
9
|
+
# ruby examples/approval.rb
|
|
10
|
+
|
|
11
|
+
require "mistri"
|
|
12
|
+
require "tmpdir"
|
|
13
|
+
|
|
14
|
+
Dir.mktmpdir do |dir|
|
|
15
|
+
store = Mistri::Stores::JSONL.new(dir)
|
|
16
|
+
session = Mistri::Session.new(store:)
|
|
17
|
+
|
|
18
|
+
send_gift = Mistri::Tool.define(
|
|
19
|
+
"send_gift", "Sends a real gift.", needs_approval: true,
|
|
20
|
+
schema: -> { string :to, "Recipient", required: true }
|
|
21
|
+
) do |args|
|
|
22
|
+
"gift queued for #{args["to"]}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
agent = Mistri.agent("claude-opus-4-8", tools: [send_gift], session:)
|
|
26
|
+
result = agent.run("Send a welcome gift to Ana.")
|
|
27
|
+
puts "suspended: #{result.awaiting_approval?} (nothing executed)"
|
|
28
|
+
|
|
29
|
+
# Any process can decide: a bare session is enough.
|
|
30
|
+
Mistri::Session.new(store:, id: session.id).approve(result.pending.first.id)
|
|
31
|
+
|
|
32
|
+
reloaded = Mistri::Session.new(store:, id: session.id)
|
|
33
|
+
resumed = Mistri.agent("claude-opus-4-8", tools: [send_gift], session: reloaded).resume
|
|
34
|
+
puts "resumed: #{resumed.status}"
|
|
35
|
+
puts resumed.text
|
|
36
|
+
end
|
data/examples/browser.rb
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Give an agent a real browser in one line: Playwright's official MCP
|
|
4
|
+
# server over the stdio wire. The allowlist grants only what this agent
|
|
5
|
+
# needs, and needs_approval gates could ride any of it. Needs
|
|
6
|
+
# ANTHROPIC_API_KEY, node, and Chrome.
|
|
7
|
+
#
|
|
8
|
+
# ruby examples/browser.rb
|
|
9
|
+
|
|
10
|
+
require "mistri"
|
|
11
|
+
|
|
12
|
+
browser = Mistri::MCP::Client.new(
|
|
13
|
+
command: ["npx", "-y", "@playwright/mcp@latest", "--browser", "chrome", "--headless"],
|
|
14
|
+
read_timeout: 180
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
begin
|
|
18
|
+
tools = Mistri::MCP.tools(browser, prefix: "web",
|
|
19
|
+
allow: %w[browser_navigate browser_snapshot])
|
|
20
|
+
|
|
21
|
+
agent = Mistri.agent("claude-opus-4-8", tools: tools,
|
|
22
|
+
system: "Use the browser tools to answer. Be brief.")
|
|
23
|
+
|
|
24
|
+
puts agent.run("Open https://example.com and report the main heading.").text
|
|
25
|
+
ensure
|
|
26
|
+
browser.close
|
|
27
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The landing-page shape: one value (a database column in production, a
|
|
4
|
+
# string here) edited in place through the document tools. A host can wrap the
|
|
5
|
+
# result in ToolResult when it also needs a separate UI payload.
|
|
6
|
+
# Needs ANTHROPIC_API_KEY.
|
|
7
|
+
#
|
|
8
|
+
# ruby examples/page_editor.rb
|
|
9
|
+
|
|
10
|
+
require "mistri"
|
|
11
|
+
|
|
12
|
+
page = +<<~HTML
|
|
13
|
+
<header>
|
|
14
|
+
<h1>Placeholder Headline</h1>
|
|
15
|
+
<p class="tagline">Ship faster.</p>
|
|
16
|
+
</header>
|
|
17
|
+
HTML
|
|
18
|
+
|
|
19
|
+
workspace = Mistri::Workspace::Single.new(
|
|
20
|
+
read: -> { page.dup },
|
|
21
|
+
write: ->(html) { page.replace(html) },
|
|
22
|
+
path: "hero.html"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
agent = Mistri.agent("claude-opus-4-8",
|
|
26
|
+
tools: Mistri::Tools.files(workspace),
|
|
27
|
+
system: "Edit the page with the document tools. Read before editing.")
|
|
28
|
+
|
|
29
|
+
agent.run("Change the h1 headline to: Gifts that land.")
|
|
30
|
+
|
|
31
|
+
puts page
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# A tool-using agent, streamed. Needs ANTHROPIC_API_KEY.
|
|
4
|
+
#
|
|
5
|
+
# ruby examples/quickstart.rb
|
|
6
|
+
|
|
7
|
+
require "mistri"
|
|
8
|
+
|
|
9
|
+
weather = Mistri::Tool.define(
|
|
10
|
+
"get_weather", "Current weather for a city.",
|
|
11
|
+
schema: -> { string :city, "City name", required: true }
|
|
12
|
+
) do |args|
|
|
13
|
+
"34C and clear in #{args["city"]}"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
agent = Mistri.agent("claude-opus-4-8", tools: [weather])
|
|
17
|
+
|
|
18
|
+
agent.run("What should I wear in Lahore today? One sentence.") do |event|
|
|
19
|
+
print event.delta if event.type == :text_delta
|
|
20
|
+
end
|
|
21
|
+
puts
|
|
@@ -41,14 +41,18 @@ module Mistri
|
|
|
41
41
|
|
|
42
42
|
private
|
|
43
43
|
|
|
44
|
-
#
|
|
45
|
-
#
|
|
44
|
+
# A parallel tool turn can legitimately exceed MySQL MEDIUMTEXT even
|
|
45
|
+
# though each call stays inside its own input limit. Postgres text is unbounded.
|
|
46
46
|
def payload_size
|
|
47
47
|
adapter = ::ActiveRecord::Base.connection_db_config.adapter.to_s
|
|
48
|
-
adapter
|
|
48
|
+
payload_size_for(adapter)
|
|
49
49
|
rescue StandardError
|
|
50
50
|
""
|
|
51
51
|
end
|
|
52
|
+
|
|
53
|
+
def payload_size_for(adapter)
|
|
54
|
+
adapter.to_s.match?(/mysql|trilogy/) ? ", size: :long" : ""
|
|
55
|
+
end
|
|
52
56
|
end
|
|
53
57
|
end
|
|
54
58
|
end
|
|
@@ -7,8 +7,8 @@ class Create<%= table_name.camelize %> < ActiveRecord::Migration[<%= ActiveRecor
|
|
|
7
7
|
t.timestamps
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
-
#
|
|
11
|
-
#
|
|
10
|
+
# The unique position is the optimistic concurrency boundary for
|
|
11
|
+
# independent appenders; a collision retries at the next position.
|
|
12
12
|
add_index :<%= table_name %>, [:session_id, :position], unique: true
|
|
13
13
|
end
|
|
14
14
|
end
|
|
@@ -12,7 +12,7 @@ class Create<%= table_name.camelize %> < ActiveRecord::Migration[<%= ActiveRecor
|
|
|
12
12
|
|
|
13
13
|
t.string :client_id
|
|
14
14
|
t.string :client_secret
|
|
15
|
-
t.string :
|
|
15
|
+
t.string :issuer
|
|
16
16
|
t.string :token_auth_method
|
|
17
17
|
t.text :access_token
|
|
18
18
|
t.text :refresh_token
|
|
@@ -4,15 +4,19 @@
|
|
|
4
4
|
class <%= class_name %> < ApplicationRecord
|
|
5
5
|
encrypts :access_token, :refresh_token, :client_secret
|
|
6
6
|
|
|
7
|
+
def self.mcp_allow_non_public = nil
|
|
8
|
+
|
|
7
9
|
# Begin the OAuth flow: persists a pending connection and returns it with
|
|
8
10
|
# the URL to send the user to.
|
|
9
|
-
def self.connect(name:, url:, client_name:, redirect_uri:, scope: nil)
|
|
11
|
+
def self.connect(name:, url:, client_name:, redirect_uri:, scope: nil, issuer: nil)
|
|
10
12
|
flow = Mistri::MCP::OAuth.start(url: url, client_name: client_name,
|
|
11
|
-
redirect_uri: redirect_uri, scope: scope
|
|
13
|
+
redirect_uri: redirect_uri, scope: scope,
|
|
14
|
+
issuer: issuer,
|
|
15
|
+
allow_non_public: mcp_allow_non_public)
|
|
12
16
|
connection = create!(name: name, url: url, status: "pending",
|
|
13
17
|
state: flow["state"], code_verifier: flow["code_verifier"],
|
|
14
18
|
client_id: flow["client_id"], client_secret: flow["client_secret"],
|
|
15
|
-
|
|
19
|
+
issuer: flow["issuer"],
|
|
16
20
|
token_auth_method: flow["token_auth_method"],
|
|
17
21
|
redirect_uri: flow["redirect_uri"])
|
|
18
22
|
[connection, flow["authorize_url"]]
|
|
@@ -25,10 +29,11 @@ class <%= class_name %> < ApplicationRecord
|
|
|
25
29
|
code_verifier: connection.code_verifier,
|
|
26
30
|
client_id: connection.client_id,
|
|
27
31
|
client_secret: connection.client_secret,
|
|
28
|
-
|
|
32
|
+
issuer: connection.issuer,
|
|
29
33
|
token_auth_method: connection.token_auth_method,
|
|
30
34
|
resource: connection.url,
|
|
31
|
-
redirect_uri: connection.redirect_uri
|
|
35
|
+
redirect_uri: connection.redirect_uri,
|
|
36
|
+
allow_non_public: mcp_allow_non_public)
|
|
32
37
|
connection.update!(status: "connected", state: nil, code_verifier: nil,
|
|
33
38
|
access_token: tokens["access_token"],
|
|
34
39
|
refresh_token: tokens["refresh_token"],
|
|
@@ -37,7 +42,8 @@ class <%= class_name %> < ApplicationRecord
|
|
|
37
42
|
end
|
|
38
43
|
|
|
39
44
|
def client
|
|
40
|
-
Mistri::MCP::Client.new(url: url, token: -> { bearer_token }
|
|
45
|
+
Mistri::MCP::Client.new(url: url, token: -> { bearer_token },
|
|
46
|
+
allow_non_public: self.class.mcp_allow_non_public)
|
|
41
47
|
end
|
|
42
48
|
|
|
43
49
|
def tools(**options)
|
|
@@ -54,8 +60,9 @@ class <%= class_name %> < ApplicationRecord
|
|
|
54
60
|
def refresh!
|
|
55
61
|
tokens = Mistri::MCP::OAuth.refresh(refresh_token: refresh_token,
|
|
56
62
|
client_id: client_id, client_secret: client_secret,
|
|
57
|
-
|
|
58
|
-
|
|
63
|
+
issuer: issuer, token_auth_method: token_auth_method,
|
|
64
|
+
resource: url,
|
|
65
|
+
allow_non_public: self.class.mcp_allow_non_public)
|
|
59
66
|
update!(access_token: tokens["access_token"],
|
|
60
67
|
refresh_token: tokens["refresh_token"] || refresh_token,
|
|
61
68
|
expires_at: tokens["expires_at"], scope: tokens["scope"] || scope)
|