mistri 0.2.1 → 0.4.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 +33 -0
- data/README.md +48 -13
- data/lib/mistri/agent.rb +35 -11
- data/lib/mistri/content.rb +9 -0
- data/lib/mistri/definition.rb +80 -0
- data/lib/mistri/providers/gemini/serializer.rb +4 -13
- data/lib/mistri/providers/openai/assembler.rb +13 -3
- data/lib/mistri/retry_policy.rb +1 -1
- data/lib/mistri/session.rb +34 -3
- data/lib/mistri/sub_agent.rb +26 -13
- data/lib/mistri/tool_context.rb +18 -7
- data/lib/mistri/tool_executor.rb +4 -2
- data/lib/mistri/version.rb +1 -1
- data/lib/mistri.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: de242bfa63b5f170166c93613fc2a2807971d45b792a6200eb60805c4f41c977
|
|
4
|
+
data.tar.gz: 362d523bba1848e2b552ece5906b1554c4a54820379c9f8e7a3e93430144a9c1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2ac330d28d201397f03912c893a538e824c5889a999f7afff3dde4cca8fe8e3a1263f4b886623f6c65fa6b732eae02962e72d4d9c6e945215f2aa724af1d82cb
|
|
7
|
+
data.tar.gz: fa27dd203af02953b311a479992e82f87e526c8b825b6be057ec0c4bffc0a76172b19af11e57ed7c522a15d0f8e063724b74dd5c5173f11118e96736457c4453
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,39 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.4.0] - 2026-07-05
|
|
9
|
+
|
|
10
|
+
- Mistri::Definition: agents as frontmatter markdown files. Config in
|
|
11
|
+
YAML, prompt in the body, {placeholders} filled at build time and
|
|
12
|
+
unfilled ones raise. Tool names and extra keys stay the host's
|
|
13
|
+
vocabulary.
|
|
14
|
+
- Agent context: Mistri.agent(context: anything) rides the run and reaches
|
|
15
|
+
every tool handler and hook as context.app, untouched.
|
|
16
|
+
- Content::Image.from_data_uri accepts base64 data: URIs directly.
|
|
17
|
+
|
|
18
|
+
- OpenAI reasoning summaries keep their paragraph structure: a reasoning
|
|
19
|
+
item's summary parts join with a blank line, and the boundary streams as
|
|
20
|
+
a thinking delta, so live views match the finished text.
|
|
21
|
+
|
|
22
|
+
## [0.3.0] - 2026-07-05
|
|
23
|
+
|
|
24
|
+
- Sessions heal at replay: a run killed mid-tool (deploy, crash) leaves
|
|
25
|
+
tool calls without results, which providers reject on every later turn.
|
|
26
|
+
Unsettled calls now replay with a synthesized interrupted result; calls
|
|
27
|
+
parked for human approval stay open for resume. The stored log is never
|
|
28
|
+
rewritten, and context assembly now reads the store exactly once.
|
|
29
|
+
- Empty completions retry: a turn that answers with no text, thinking, or
|
|
30
|
+
tool calls (an intermittent provider behavior) now retries under the
|
|
31
|
+
standard policy instead of ending the run in silence.
|
|
32
|
+
- Gemini: consecutive user turns are no longer merged. Mixing a text part
|
|
33
|
+
into a functionResponse turn makes Gemini answer an empty candidate, so
|
|
34
|
+
steers and resumed prompts stay their own turns; Gemini accepts this.
|
|
35
|
+
- The spawn tool takes an optional `name`: the model labels each worker,
|
|
36
|
+
and the label rides origin tags and the transcript link, so fan-out
|
|
37
|
+
streams read as `pricing-scout#a41f` instead of `spawn#a41f`.
|
|
38
|
+
- The spawn tool's `model` parameter names the default child model in its
|
|
39
|
+
description, so the model's choice (or non-choice) is informed.
|
|
40
|
+
|
|
8
41
|
## [0.2.1] - 2026-07-05
|
|
9
42
|
|
|
10
43
|
- The gem homepage and documentation links now point at
|
data/README.md
CHANGED
|
@@ -7,10 +7,12 @@
|
|
|
7
7
|
|
|
8
8
|
<p align="center"><strong>mistri</strong>, the agent harness for Ruby applications.</p>
|
|
9
9
|
|
|
10
|
+
<p align="center"><a href="https://mistri.sh">mistri.sh</a> · <a href="https://mistri.sh/docs/getting-started/">docs</a></p>
|
|
11
|
+
|
|
10
12
|
<p align="center">
|
|
11
13
|
<a href="https://rubygems.org/gems/mistri"><img alt="Gem Version" src="https://img.shields.io/gem/v/mistri"></a>
|
|
12
14
|
<a href="https://github.com/mcheemaa/mistri/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/mcheemaa/mistri/actions/workflows/ci.yml/badge.svg"></a>
|
|
13
|
-
<a href="https://codecov.io/gh/mcheemaa/mistri"><img alt="Coverage" src="https://
|
|
15
|
+
<a href="https://codecov.io/gh/mcheemaa/mistri"><img alt="Coverage" src="https://img.shields.io/codecov/c/github/mcheemaa/mistri"></a>
|
|
14
16
|
<a href="mistri.gemspec"><img alt="Ruby >= 3.2" src="https://img.shields.io/badge/ruby-%3E%3D%203.2-CC342D"></a>
|
|
15
17
|
<a href="Gemfile"><img alt="Runtime dependencies: zero" src="https://img.shields.io/badge/runtime_deps-0-brightgreen"></a>
|
|
16
18
|
<a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-blue.svg"></a>
|
|
@@ -100,6 +102,20 @@ Mistri::Tool.define("edit_page", "Applies a page edit.") do |args|
|
|
|
100
102
|
end
|
|
101
103
|
```
|
|
102
104
|
|
|
105
|
+
Handlers and hooks can take the run's context as a second argument, and
|
|
106
|
+
`context.app` carries whatever object you pass as `Mistri.agent(context:)`
|
|
107
|
+
— the acting user, a tenant, a request — so tools stay authorization-aware
|
|
108
|
+
without closure gymnastics:
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
agent = Mistri.agent("claude-opus-4-8", tools: tools,
|
|
112
|
+
context: { traveler: current_traveler })
|
|
113
|
+
|
|
114
|
+
Mistri::Tool.define("book_hotel", "Books the chosen hotel.") do |args, context|
|
|
115
|
+
Bookings.create!(args, traveler: context.app[:traveler])
|
|
116
|
+
end
|
|
117
|
+
```
|
|
118
|
+
|
|
103
119
|
## Human approval
|
|
104
120
|
|
|
105
121
|
Mark a tool `needs_approval: true` (or a predicate on its arguments) and the
|
|
@@ -108,12 +124,12 @@ The decision is a one-line session write from any process, any time later;
|
|
|
108
124
|
`resume` settles it and carries on.
|
|
109
125
|
|
|
110
126
|
```ruby
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
127
|
+
book_hotel = Mistri::Tool.define("book_hotel", "Books the chosen hotel.",
|
|
128
|
+
needs_approval: ->(args) { args["total_usd"].to_i > 500 }) do |args|
|
|
129
|
+
Bookings.create!(args)
|
|
114
130
|
end
|
|
115
131
|
|
|
116
|
-
result = agent.run("
|
|
132
|
+
result = agent.run("Book the corner suite for the Lisbon trip")
|
|
117
133
|
result.awaiting_approval? # => true; nothing executed
|
|
118
134
|
|
|
119
135
|
# Days later, in a controller:
|
|
@@ -213,6 +229,20 @@ A skill is a `SKILL.md` (or flat `.md`) with `name:`/`description:`
|
|
|
213
229
|
frontmatter, or built from database rows with
|
|
214
230
|
`Mistri::Skill.new(name:, description:, body:)`.
|
|
215
231
|
|
|
232
|
+
## Definitions
|
|
233
|
+
|
|
234
|
+
An agent as a markdown file: YAML frontmatter for config, the body as the
|
|
235
|
+
prompt, `{placeholders}` filled at build time (unfilled ones raise). Tool
|
|
236
|
+
names and any extra keys stay your vocabulary; the gem only reads the
|
|
237
|
+
file.
|
|
238
|
+
|
|
239
|
+
```ruby
|
|
240
|
+
definition = Mistri::Definition.load("app/agents/trip_planner.md")
|
|
241
|
+
agent = Mistri.agent(definition.model,
|
|
242
|
+
system: definition.render(first_name: traveler.first_name),
|
|
243
|
+
tools: registry.build(definition.tools, traveler))
|
|
244
|
+
```
|
|
245
|
+
|
|
216
246
|
## Sub-agents
|
|
217
247
|
|
|
218
248
|
Delegate to a child agent with a clean context: exploration fills the
|
|
@@ -230,8 +260,9 @@ agent = Mistri.agent("claude-opus-4-8", tools: [researcher.tool])
|
|
|
230
260
|
```
|
|
231
261
|
|
|
232
262
|
Or hand the model an open spawn tool and let it compose its own workers:
|
|
233
|
-
instructions, a tool subset, and a
|
|
234
|
-
Several spawns in one turn fan out in
|
|
263
|
+
a name for the event stream, instructions, a tool subset, and a
|
|
264
|
+
host-allowlisted model per child. Several spawns in one turn fan out in
|
|
265
|
+
parallel:
|
|
235
266
|
|
|
236
267
|
```ruby
|
|
237
268
|
spawn = Mistri::SubAgent.spawner(provider: provider, tools: [fetch_page, search])
|
|
@@ -342,6 +373,7 @@ policy = Mistri::RetryPolicy.new(attempts: 3)
|
|
|
342
373
|
|
|
343
374
|
```ruby
|
|
344
375
|
photo = Mistri::Content::Image.from_bytes(File.binread("chart.png"), mime_type: "image/png")
|
|
376
|
+
photo = Mistri::Content::Image.from_data_uri(params[:image]) # canvases and uploads
|
|
345
377
|
agent.run("What trend does this chart show?", images: [photo])
|
|
346
378
|
|
|
347
379
|
Mistri.agent("gpt-5.5", provider_options: { reasoning: { effort: "high" } })
|
|
@@ -351,18 +383,21 @@ Mistri.agent("claude-opus-4-8", provider_options: { cache: false })
|
|
|
351
383
|
## Testing
|
|
352
384
|
|
|
353
385
|
`rake test` is hermetic and fast. `rake integration` runs every feature end
|
|
354
|
-
to end against real provider APIs, once per model in the matrix
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
386
|
+
to end against real provider APIs, once per model in the matrix: an
|
|
387
|
+
Anthropic, an OpenAI, and a Gemini model by default. Scenarios assert that
|
|
388
|
+
coined codenames (a ghost of a word like `Wraithowyn` exists in no training
|
|
389
|
+
data) flowed through tool results, summaries, and child agents: proof of
|
|
390
|
+
information flow, not model knowledge.
|
|
358
391
|
|
|
359
392
|
```console
|
|
360
|
-
$
|
|
393
|
+
$ bundle exec rake integration
|
|
394
|
+
$ MISTRI_INTEGRATION_MODELS=claude-opus-4-8 bundle exec rake integration
|
|
361
395
|
```
|
|
362
396
|
|
|
363
397
|
## Roadmap
|
|
364
398
|
|
|
365
|
-
|
|
399
|
+
Next up: strict tool schemas, provider-native MCP passthrough, and the
|
|
400
|
+
hardening that falls out of the first production applications.
|
|
366
401
|
|
|
367
402
|
## Credits
|
|
368
403
|
|
data/lib/mistri/agent.rb
CHANGED
|
@@ -30,7 +30,8 @@ module Mistri
|
|
|
30
30
|
# return a replacement result, or nil to keep the original.
|
|
31
31
|
def initialize(provider:, session: nil, system: nil, tools: [], budget: nil,
|
|
32
32
|
max_concurrency: 4, transform_context: nil, compaction: Compaction.new,
|
|
33
|
-
retries: RetryPolicy.new, skills: [], before_tool: nil, after_tool: nil
|
|
33
|
+
retries: RetryPolicy.new, skills: [], before_tool: nil, after_tool: nil,
|
|
34
|
+
context: nil)
|
|
34
35
|
@provider = provider
|
|
35
36
|
@session = session || Session.new(store: Stores::Memory.new)
|
|
36
37
|
skills = skills.is_a?(String) ? Skills.load(skills) : Array(skills)
|
|
@@ -46,10 +47,17 @@ module Mistri
|
|
|
46
47
|
@retries = retries || nil
|
|
47
48
|
@before_tool = before_tool
|
|
48
49
|
@after_tool = after_tool
|
|
50
|
+
@context = context
|
|
49
51
|
end
|
|
50
52
|
|
|
51
53
|
attr_reader :session
|
|
52
54
|
|
|
55
|
+
# What retries see when a completion answers nothing at all.
|
|
56
|
+
EMPTY_COMPLETION_ERROR = {
|
|
57
|
+
"type" => "EmptyCompletion",
|
|
58
|
+
"message" => "the provider returned an empty completion"
|
|
59
|
+
}.freeze
|
|
60
|
+
|
|
53
61
|
# Run one exchange: append the user turn, then loop until the model
|
|
54
62
|
# answers without tools, a gated tool suspends the run, the run aborts,
|
|
55
63
|
# or a budget stops it.
|
|
@@ -212,9 +220,10 @@ module Mistri
|
|
|
212
220
|
tools: @tools.map(&:spec), signal: signal,
|
|
213
221
|
output_schema: output_schema, &emit)
|
|
214
222
|
attempt += 1
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
223
|
+
error = turn_error(message)
|
|
224
|
+
if retry_turn?(error, attempt, signal)
|
|
225
|
+
pause = @retries.delay(attempt, error["retry_after"])
|
|
226
|
+
record_retry(message, error, attempt, pause, &emit)
|
|
218
227
|
wait(pause, signal)
|
|
219
228
|
next unless signal&.aborted?
|
|
220
229
|
end
|
|
@@ -223,15 +232,29 @@ module Mistri
|
|
|
223
232
|
end
|
|
224
233
|
end
|
|
225
234
|
|
|
226
|
-
|
|
227
|
-
|
|
235
|
+
# The provider's own error when the turn failed, or a synthesized one
|
|
236
|
+
# for a completion that answers nothing: providers intermittently stop
|
|
237
|
+
# with an empty candidate, and a retry usually clears it.
|
|
238
|
+
def turn_error(message)
|
|
239
|
+
return message.error if message.stop_reason == StopReason::ERROR
|
|
240
|
+
|
|
241
|
+
EMPTY_COMPLETION_ERROR if empty_completion?(message)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def empty_completion?(message)
|
|
245
|
+
message.stop_reason == StopReason::STOP &&
|
|
246
|
+
message.content.all? { |block| block.is_a?(Content::Text) && block.text.to_s.strip.empty? }
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def retry_turn?(error, attempt, signal)
|
|
250
|
+
return false unless @retries && error
|
|
228
251
|
return false if signal&.aborted?
|
|
229
252
|
|
|
230
|
-
@retries.retry?(
|
|
253
|
+
@retries.retry?(error, attempt)
|
|
231
254
|
end
|
|
232
255
|
|
|
233
|
-
def record_retry(message, attempt, pause, &emit)
|
|
234
|
-
@session.append("retry", "attempt" => attempt, "error" =>
|
|
256
|
+
def record_retry(message, error, attempt, pause, &emit)
|
|
257
|
+
@session.append("retry", "attempt" => attempt, "error" => error,
|
|
235
258
|
"delay" => pause.round(2))
|
|
236
259
|
note = format("attempt %<attempt>d failed; retrying in %<pause>.1fs",
|
|
237
260
|
attempt: attempt, pause: pause)
|
|
@@ -281,7 +304,8 @@ module Mistri
|
|
|
281
304
|
|
|
282
305
|
results = ToolExecutor.call(calls, @tools_by_name, signal: signal,
|
|
283
306
|
max_concurrency: @max_concurrency,
|
|
284
|
-
session: @session, emit: emit
|
|
307
|
+
session: @session, emit: emit,
|
|
308
|
+
app: @context)
|
|
285
309
|
context = hook_context(signal, emit)
|
|
286
310
|
results.each do |call, result, seconds|
|
|
287
311
|
result = rewrite(call, result, context) if @after_tool
|
|
@@ -316,7 +340,7 @@ module Mistri
|
|
|
316
340
|
end
|
|
317
341
|
|
|
318
342
|
def hook_context(signal, emit)
|
|
319
|
-
ToolContext.new(session: @session, signal: signal, emit: emit)
|
|
343
|
+
ToolContext.new(session: @session, signal: signal, emit: emit, app: @context)
|
|
320
344
|
end
|
|
321
345
|
|
|
322
346
|
# The tool message carries both channels; the :tool_result event exposes
|
data/lib/mistri/content.rb
CHANGED
|
@@ -50,6 +50,15 @@ module Mistri
|
|
|
50
50
|
# second copy of what can be a multi-megabyte payload.
|
|
51
51
|
def self.from_bytes(bytes, mime_type:) = new(data: [bytes.b].pack("m0").freeze, mime_type:)
|
|
52
52
|
|
|
53
|
+
# Browsers, canvases, and upload pipelines hand images around as
|
|
54
|
+
# data: URIs; accept them directly.
|
|
55
|
+
def self.from_data_uri(uri)
|
|
56
|
+
match = /\Adata:(?<mime>[^;,]+);base64,(?<data>.+)\z/m.match(uri.to_s)
|
|
57
|
+
raise ArgumentError, "not a base64 data: URI" unless match
|
|
58
|
+
|
|
59
|
+
new(data: match[:data].delete("\n").freeze, mime_type: match[:mime])
|
|
60
|
+
end
|
|
61
|
+
|
|
53
62
|
def initialize(data:, mime_type:)
|
|
54
63
|
super(data: Content.freeze_string(data), mime_type: Content.freeze_string(mime_type))
|
|
55
64
|
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Mistri
|
|
6
|
+
# An agent definition: a markdown file whose YAML frontmatter carries the
|
|
7
|
+
# config and whose body is the prompt. Prompts stay editable prose in
|
|
8
|
+
# files a reviewer can diff; code stays out of them.
|
|
9
|
+
#
|
|
10
|
+
# ---
|
|
11
|
+
# role: Trip Planner
|
|
12
|
+
# model: claude-opus-4-8
|
|
13
|
+
# tools:
|
|
14
|
+
# - search_flights
|
|
15
|
+
# - book_hotel
|
|
16
|
+
# ---
|
|
17
|
+
# You plan trips end to end. Address the traveler as {first_name}.
|
|
18
|
+
#
|
|
19
|
+
# definition = Mistri::Definition.load("app/agents/trip_planner.md")
|
|
20
|
+
# agent = Mistri.agent(definition.model,
|
|
21
|
+
# system: definition.render(first_name: traveler.first_name),
|
|
22
|
+
# tools: registry.build(definition.tools, traveler))
|
|
23
|
+
#
|
|
24
|
+
# The gem reads the file; what tool names mean, and what any extra
|
|
25
|
+
# frontmatter keys mean, stays the host's vocabulary via #config.
|
|
26
|
+
class Definition
|
|
27
|
+
FRONTMATTER = /\A---\s*\n(?<yaml>.*?)\n---\s*\n(?<body>.*)\z/m
|
|
28
|
+
|
|
29
|
+
attr_reader :name, :body, :config
|
|
30
|
+
|
|
31
|
+
def self.load(path)
|
|
32
|
+
match = FRONTMATTER.match(File.read(path))
|
|
33
|
+
raise ConfigurationError, "#{path} has no frontmatter" unless match
|
|
34
|
+
|
|
35
|
+
config = YAML.safe_load(match[:yaml]) || {}
|
|
36
|
+
raise ConfigurationError, "#{path} frontmatter is not a mapping" unless config.is_a?(Hash)
|
|
37
|
+
|
|
38
|
+
new(name: File.basename(path.to_s, ".md"), config: config, body: match[:body].strip)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def initialize(name:, config:, body:)
|
|
42
|
+
@name = name.to_s
|
|
43
|
+
@config = config.freeze
|
|
44
|
+
@body = body
|
|
45
|
+
freeze
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def model = config["model"]
|
|
49
|
+
|
|
50
|
+
def role = config["role"]
|
|
51
|
+
|
|
52
|
+
# Tool declarations as a name => options map. A bare list means no
|
|
53
|
+
# options; a map form carries per-tool options in the host's
|
|
54
|
+
# vocabulary (gates, caching, whatever the host honors).
|
|
55
|
+
def tools
|
|
56
|
+
raw = config["tools"]
|
|
57
|
+
entries = case raw
|
|
58
|
+
when Hash then raw.transform_values { |options| options || {} }
|
|
59
|
+
when Array then raw.to_h { |tool| [tool, {}] }
|
|
60
|
+
else {}
|
|
61
|
+
end
|
|
62
|
+
entries.transform_keys(&:to_s)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def tool_names = tools.keys
|
|
66
|
+
|
|
67
|
+
# The body with {placeholders} filled in. An unfilled placeholder
|
|
68
|
+
# raises: a prompt silently addressing "{first_name}" is worse than an
|
|
69
|
+
# error. A value of nil renders as empty, which lets optional context
|
|
70
|
+
# collapse cleanly.
|
|
71
|
+
def render(vars = {})
|
|
72
|
+
vars = vars.transform_keys(&:to_s)
|
|
73
|
+
body.gsub(/\{(\w+)\}/) do
|
|
74
|
+
vars.fetch(Regexp.last_match(1)) do |key|
|
|
75
|
+
raise ConfigurationError, "no value for {#{key}} in the #{name} definition"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -10,26 +10,17 @@ module Mistri
|
|
|
10
10
|
# thought signatures echo back verbatim on the exact part they arrived
|
|
11
11
|
# with, but only for messages this provider produced; a foreign
|
|
12
12
|
# signature would be rejected. Thinking summaries are output-only and
|
|
13
|
-
# never replay.
|
|
13
|
+
# never replay. Consecutive user turns stay separate: Gemini accepts
|
|
14
|
+
# them, and mixing a text part into a functionResponse turn makes it
|
|
15
|
+
# answer an empty candidate.
|
|
14
16
|
module Serializer
|
|
15
17
|
module_function
|
|
16
18
|
|
|
17
19
|
def contents(history)
|
|
18
20
|
groups = history.reject(&:system?).chunk_while { |a, b| a.tool? && b.tool? }
|
|
19
|
-
|
|
21
|
+
groups.filter_map do |group|
|
|
20
22
|
group.first.tool? ? tool_turn(group) : turn(group.first)
|
|
21
23
|
end
|
|
22
|
-
merge_user_runs(turns)
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
# A steered run puts a user message right behind tool results, and
|
|
26
|
-
# both serialize as user turns. Gemini expects turns to alternate, so
|
|
27
|
-
# consecutive user turns merge into one.
|
|
28
|
-
def merge_user_runs(turns)
|
|
29
|
-
turns.chunk_while { |a, b| a[:role] == "user" && b[:role] == "user" }
|
|
30
|
-
.map do |run|
|
|
31
|
-
run.length == 1 ? run.first : { role: "user", parts: run.flat_map { |t| t[:parts] } }
|
|
32
|
-
end
|
|
33
24
|
end
|
|
34
25
|
|
|
35
26
|
def system_instruction(system)
|
|
@@ -25,7 +25,8 @@ module Mistri
|
|
|
25
25
|
case record["type"]
|
|
26
26
|
when "response.output_item.added" then start_item(record["item"], &)
|
|
27
27
|
when "response.output_text.delta" then text_delta(record["delta"], &)
|
|
28
|
-
when "response.reasoning_summary_text.delta"
|
|
28
|
+
when "response.reasoning_summary_text.delta"
|
|
29
|
+
thinking_delta(record["delta"], record["summary_index"], &)
|
|
29
30
|
when "response.function_call_arguments.delta" then arguments_delta(record["delta"], &)
|
|
30
31
|
when "response.output_item.done" then finish_item(record["item"], &)
|
|
31
32
|
when "response.completed", "response.incomplete", "response.failed"
|
|
@@ -83,6 +84,7 @@ module Mistri
|
|
|
83
84
|
return unless kind
|
|
84
85
|
|
|
85
86
|
@current = Builder.new(kind, @blocks.size, +"", +"")
|
|
87
|
+
@summary_part = nil
|
|
86
88
|
emit_event(:"#{kind}_start", content_index: @current.index, &)
|
|
87
89
|
end
|
|
88
90
|
|
|
@@ -93,9 +95,17 @@ module Mistri
|
|
|
93
95
|
emit_event(:text_delta, content_index: @current.index, delta: delta, &)
|
|
94
96
|
end
|
|
95
97
|
|
|
96
|
-
|
|
98
|
+
# A reasoning item carries one or more summary parts, each its own
|
|
99
|
+
# paragraph; the boundary streams as a delta so live views keep the
|
|
100
|
+
# break the finished text will have.
|
|
101
|
+
def thinking_delta(delta, part, &)
|
|
97
102
|
return unless @current
|
|
98
103
|
|
|
104
|
+
if @summary_part && part && part != @summary_part
|
|
105
|
+
@current.text << "\n\n"
|
|
106
|
+
emit_event(:thinking_delta, content_index: @current.index, delta: "\n\n", &)
|
|
107
|
+
end
|
|
108
|
+
@summary_part = part if part
|
|
99
109
|
@current.text << delta.to_s
|
|
100
110
|
emit_event(:thinking_delta, content_index: @current.index, delta: delta, &)
|
|
101
111
|
end
|
|
@@ -131,7 +141,7 @@ module Mistri
|
|
|
131
141
|
text = Array(item["content"]).filter_map { |part| part["text"] }.join
|
|
132
142
|
Content::Text.new(text: text, signature: item["id"])
|
|
133
143
|
when :thinking
|
|
134
|
-
summary = Array(item["summary"]).filter_map { |part| part["text"] }.join
|
|
144
|
+
summary = Array(item["summary"]).filter_map { |part| part["text"] }.join("\n\n")
|
|
135
145
|
Content::Thinking.new(thinking: summary, signature: JSON.generate(item))
|
|
136
146
|
when :toolcall
|
|
137
147
|
ToolCall.new(id: item["call_id"], name: item["name"],
|
data/lib/mistri/retry_policy.rb
CHANGED
|
@@ -11,7 +11,7 @@ module Mistri
|
|
|
11
11
|
class RetryPolicy
|
|
12
12
|
RETRYABLE_STATUSES = [408, 429, 500, 502, 503, 504, 529].freeze
|
|
13
13
|
RETRYABLE_TYPES = %w[ProviderError RateLimitError OverloadedError ServerError
|
|
14
|
-
TruncatedStream].freeze
|
|
14
|
+
TruncatedStream EmptyCompletion].freeze
|
|
15
15
|
|
|
16
16
|
attr_reader :attempts, :base, :max_delay
|
|
17
17
|
|
data/lib/mistri/session.rb
CHANGED
|
@@ -40,16 +40,25 @@ module Mistri
|
|
|
40
40
|
# The conversation as the model replays it.
|
|
41
41
|
def messages = replay.map(&:first)
|
|
42
42
|
|
|
43
|
+
# What a run killed mid-tool answers in place of the result it never got.
|
|
44
|
+
INTERRUPTED_RESULT = "[interrupted: the run stopped before this tool returned]"
|
|
45
|
+
|
|
43
46
|
# Replay messages paired with the entry index each came from, starting at
|
|
44
47
|
# the latest compaction boundary. The synthetic summary message carries a
|
|
45
48
|
# nil index. Compaction places its cuts by these indexes; the full entry
|
|
46
|
-
# log stays in the store for transcript views.
|
|
49
|
+
# log stays in the store for transcript views. One store read builds the
|
|
50
|
+
# whole context, healed: a crash that left tool calls unanswered would
|
|
51
|
+
# brick every later turn with a provider rejection, so unsettled calls
|
|
52
|
+
# get a synthesized interrupted result. Calls parked for human approval
|
|
53
|
+
# stay open; resume owns those.
|
|
47
54
|
def replay
|
|
48
|
-
|
|
55
|
+
log = entries
|
|
56
|
+
compaction = log.reverse_each.find { |entry| entry["type"] == "compaction" }
|
|
49
57
|
from = compaction ? compaction["kept_from"] : 0
|
|
50
|
-
pairs =
|
|
58
|
+
pairs = log.each_with_index.filter_map do |entry, index|
|
|
51
59
|
[Message.from_h(entry["message"]), index] if index >= from && entry["type"] == "message"
|
|
52
60
|
end
|
|
61
|
+
pairs = heal(pairs, parked_call_ids(log))
|
|
53
62
|
compaction ? [[summary_message(compaction["summary"]), nil], *pairs] : pairs
|
|
54
63
|
end
|
|
55
64
|
|
|
@@ -102,6 +111,28 @@ module Mistri
|
|
|
102
111
|
|
|
103
112
|
private
|
|
104
113
|
|
|
114
|
+
def heal(pairs, parked)
|
|
115
|
+
answered = pairs.map(&:first).select(&:tool?).to_set(&:tool_call_id)
|
|
116
|
+
pairs.flat_map do |message, index|
|
|
117
|
+
dangling = if message.assistant?
|
|
118
|
+
message.tool_calls.reject do |call|
|
|
119
|
+
answered.include?(call.id) || parked.include?(call.id)
|
|
120
|
+
end
|
|
121
|
+
else
|
|
122
|
+
[]
|
|
123
|
+
end
|
|
124
|
+
[[message, index]] + dangling.map do |call|
|
|
125
|
+
[Message.tool(content: INTERRUPTED_RESULT, tool_call_id: call.id,
|
|
126
|
+
tool_name: call.name), nil]
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def parked_call_ids(log)
|
|
132
|
+
log.filter_map { |entry| entry.dig("call", "id") if entry["type"] == "approval_request" }
|
|
133
|
+
.to_set
|
|
134
|
+
end
|
|
135
|
+
|
|
105
136
|
def summary_message(summary)
|
|
106
137
|
Message.user("#{Compaction::SUMMARY_PREFACE}\n\n#{summary}")
|
|
107
138
|
end
|
data/lib/mistri/sub_agent.rb
CHANGED
|
@@ -18,8 +18,8 @@ module Mistri
|
|
|
18
18
|
# )
|
|
19
19
|
# agent = Mistri::Agent.new(provider:, tools: [researcher.tool])
|
|
20
20
|
#
|
|
21
|
-
# and the open spawn tool, where the model
|
|
22
|
-
# instructions, picks a tool subset, and may
|
|
21
|
+
# and the open spawn tool, where the model composes each worker: names
|
|
22
|
+
# it, writes its instructions, picks a tool subset, and may pick a model:
|
|
23
23
|
#
|
|
24
24
|
# spawn = Mistri::SubAgent.spawner(provider:, tools: [fetch_page, search])
|
|
25
25
|
#
|
|
@@ -84,9 +84,10 @@ module Mistri
|
|
|
84
84
|
|
|
85
85
|
class << self
|
|
86
86
|
# The open spawn tool over a pool of tools the host allows children to
|
|
87
|
-
# use. The model may
|
|
88
|
-
#
|
|
89
|
-
#
|
|
87
|
+
# use. The model may name the worker (a display label riding origins
|
|
88
|
+
# and the transcript link) and grant a tool subset by name; models: is
|
|
89
|
+
# the host's allowlist of child model ids — without one, no model
|
|
90
|
+
# choice is offered at all, so a hallucinated id can never construct a
|
|
90
91
|
# provider or land children on an expensive model.
|
|
91
92
|
def spawner(provider:, tools: [], models: [], needs_approval: false, **agent_options)
|
|
92
93
|
forbid_gated!(tools)
|
|
@@ -94,10 +95,11 @@ module Mistri
|
|
|
94
95
|
raise ConfigurationError, "the spawn tool never goes in its own pool"
|
|
95
96
|
end
|
|
96
97
|
|
|
98
|
+
schema = spawner_schema(tools, models, default_model(provider))
|
|
97
99
|
Tool.define("spawn_agent", SPAWNER_DESCRIPTION,
|
|
98
|
-
needs_approval: needs_approval,
|
|
99
|
-
|
|
100
|
-
|
|
100
|
+
needs_approval: needs_approval, schema: schema) do |args, context|
|
|
101
|
+
run_child(label: child_label(args["name"]),
|
|
102
|
+
provider: child_provider(provider, args["model"], models),
|
|
101
103
|
system: args.fetch("instructions"),
|
|
102
104
|
tools: pick(tools, args["tools"]),
|
|
103
105
|
task: args.fetch("task"), context: context, **agent_options)
|
|
@@ -173,22 +175,33 @@ module Mistri
|
|
|
173
175
|
end
|
|
174
176
|
end
|
|
175
177
|
|
|
176
|
-
def spawner_schema(pool, models)
|
|
178
|
+
def spawner_schema(pool, models, default)
|
|
177
179
|
tool_names = pool.map(&:name)
|
|
180
|
+
fallback = default ? " (default: #{default})" : ""
|
|
178
181
|
lambda do
|
|
182
|
+
string :name, "A short name for this worker, shown wherever its events appear"
|
|
179
183
|
string :task, "The child's complete task", required: true
|
|
180
184
|
string :instructions, "The child's system prompt", required: true
|
|
181
185
|
if tool_names.any?
|
|
182
186
|
array :tools, "Subset of tools to grant (default: all)",
|
|
183
187
|
items: { type: "string", enum: tool_names }
|
|
184
188
|
end
|
|
185
|
-
if models.any?
|
|
186
|
-
string :model, "Model for the child (default: the configured one)",
|
|
187
|
-
enum: models
|
|
188
|
-
end
|
|
189
|
+
string :model, "Model for the child#{fallback}", enum: models if models.any?
|
|
189
190
|
end
|
|
190
191
|
end
|
|
191
192
|
|
|
193
|
+
def default_model(provider)
|
|
194
|
+
provider.model if provider.respond_to?(:model)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# The label rides origins as "label#id" and joins nesting with ">",
|
|
198
|
+
# so those separators squeeze to hyphens along with whitespace.
|
|
199
|
+
def child_label(raw)
|
|
200
|
+
label = raw.to_s.gsub(/[#>\s]+/, "-").squeeze("-")[0, 32]
|
|
201
|
+
label = label.delete_prefix("-").delete_suffix("-")
|
|
202
|
+
label.empty? ? "spawn" : label
|
|
203
|
+
end
|
|
204
|
+
|
|
192
205
|
def child_provider(default, requested, models)
|
|
193
206
|
return default if requested.nil? || requested.to_s.empty?
|
|
194
207
|
unless models.include?(requested)
|
data/lib/mistri/tool_context.rb
CHANGED
|
@@ -2,13 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
module Mistri
|
|
4
4
|
# What a tool handler may know about the run it executes inside: the
|
|
5
|
-
# caller's session, the abort signal,
|
|
6
|
-
# it as an optional second argument — a
|
|
7
|
-
# lambda opts in by accepting two
|
|
8
|
-
# any tool that spawns work,
|
|
9
|
-
# progress can use it the same
|
|
10
|
-
|
|
11
|
-
|
|
5
|
+
# caller's session, the abort signal, the event stream, and the host's
|
|
6
|
+
# own context object. Handlers take it as an optional second argument — a
|
|
7
|
+
# proc ignores it invisibly, a lambda opts in by accepting two
|
|
8
|
+
# parameters. Sub-agents are built on it; any tool that spawns work,
|
|
9
|
+
# links records to the session, or streams progress can use it the same
|
|
10
|
+
# way.
|
|
11
|
+
#
|
|
12
|
+
# app carries whatever the host passes as Agent.new(context:) — the
|
|
13
|
+
# acting user, a tenant, a request — untouched. The gem provides the
|
|
14
|
+
# slot, never the vocabulary:
|
|
15
|
+
#
|
|
16
|
+
# agent = Mistri.agent("claude-opus-4-8", tools: tools,
|
|
17
|
+
# context: { traveler: current_traveler })
|
|
18
|
+
# Mistri::Tool.define("book_hotel", "Books the chosen hotel.") do |args, context|
|
|
19
|
+
# Bookings.create(args, traveler: context.app[:traveler])
|
|
20
|
+
# end
|
|
21
|
+
ToolContext = Data.define(:session, :signal, :emit, :app) do
|
|
22
|
+
def initialize(session: nil, signal: nil, emit: nil, app: nil)
|
|
12
23
|
super
|
|
13
24
|
end
|
|
14
25
|
end
|
data/lib/mistri/tool_executor.rb
CHANGED
|
@@ -16,10 +16,12 @@ module Mistri
|
|
|
16
16
|
|
|
17
17
|
module_function
|
|
18
18
|
|
|
19
|
-
def call(calls, tools_by_name, signal: nil, max_concurrency: 4, session: nil, emit: nil
|
|
19
|
+
def call(calls, tools_by_name, signal: nil, max_concurrency: 4, session: nil, emit: nil,
|
|
20
|
+
app: nil)
|
|
20
21
|
return [] if calls.empty?
|
|
21
22
|
|
|
22
|
-
context = ToolContext.new(session: session, signal: signal, emit: thread_safe(emit)
|
|
23
|
+
context = ToolContext.new(session: session, signal: signal, emit: thread_safe(emit),
|
|
24
|
+
app: app)
|
|
23
25
|
results = Array.new(calls.length)
|
|
24
26
|
queue = Queue.new
|
|
25
27
|
calls.each_with_index { |call, index| queue << [call, index] }
|
data/lib/mistri/version.rb
CHANGED
data/lib/mistri.rb
CHANGED
|
@@ -23,6 +23,7 @@ require_relative "mistri/workspace/directory"
|
|
|
23
23
|
require_relative "mistri/workspace/single"
|
|
24
24
|
require_relative "mistri/memory"
|
|
25
25
|
require_relative "mistri/tools"
|
|
26
|
+
require_relative "mistri/definition"
|
|
26
27
|
require_relative "mistri/skill"
|
|
27
28
|
require_relative "mistri/skills"
|
|
28
29
|
require_relative "mistri/tool_executor"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mistri
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Muhammad Ahmed Cheema
|
|
@@ -34,6 +34,7 @@ files:
|
|
|
34
34
|
- lib/mistri/compaction.rb
|
|
35
35
|
- lib/mistri/compactor.rb
|
|
36
36
|
- lib/mistri/content.rb
|
|
37
|
+
- lib/mistri/definition.rb
|
|
37
38
|
- lib/mistri/edit.rb
|
|
38
39
|
- lib/mistri/errors.rb
|
|
39
40
|
- lib/mistri/event.rb
|