mistri 0.3.0 → 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 +14 -0
- data/README.md +34 -5
- data/lib/mistri/agent.rb +6 -3
- data/lib/mistri/content.rb +9 -0
- data/lib/mistri/definition.rb +80 -0
- data/lib/mistri/providers/openai/assembler.rb +13 -3
- 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,20 @@ 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
|
+
|
|
8
22
|
## [0.3.0] - 2026-07-05
|
|
9
23
|
|
|
10
24
|
- Sessions heal at replay: a run killed mid-tool (deploy, crash) leaves
|
data/README.md
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
<p align="center">
|
|
13
13
|
<a href="https://rubygems.org/gems/mistri"><img alt="Gem Version" src="https://img.shields.io/gem/v/mistri"></a>
|
|
14
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>
|
|
15
|
-
<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>
|
|
16
16
|
<a href="mistri.gemspec"><img alt="Ruby >= 3.2" src="https://img.shields.io/badge/ruby-%3E%3D%203.2-CC342D"></a>
|
|
17
17
|
<a href="Gemfile"><img alt="Runtime dependencies: zero" src="https://img.shields.io/badge/runtime_deps-0-brightgreen"></a>
|
|
18
18
|
<a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-blue.svg"></a>
|
|
@@ -102,6 +102,20 @@ Mistri::Tool.define("edit_page", "Applies a page edit.") do |args|
|
|
|
102
102
|
end
|
|
103
103
|
```
|
|
104
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
|
+
|
|
105
119
|
## Human approval
|
|
106
120
|
|
|
107
121
|
Mark a tool `needs_approval: true` (or a predicate on its arguments) and the
|
|
@@ -110,12 +124,12 @@ The decision is a one-line session write from any process, any time later;
|
|
|
110
124
|
`resume` settles it and carries on.
|
|
111
125
|
|
|
112
126
|
```ruby
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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)
|
|
116
130
|
end
|
|
117
131
|
|
|
118
|
-
result = agent.run("
|
|
132
|
+
result = agent.run("Book the corner suite for the Lisbon trip")
|
|
119
133
|
result.awaiting_approval? # => true; nothing executed
|
|
120
134
|
|
|
121
135
|
# Days later, in a controller:
|
|
@@ -215,6 +229,20 @@ A skill is a `SKILL.md` (or flat `.md`) with `name:`/`description:`
|
|
|
215
229
|
frontmatter, or built from database rows with
|
|
216
230
|
`Mistri::Skill.new(name:, description:, body:)`.
|
|
217
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
|
+
|
|
218
246
|
## Sub-agents
|
|
219
247
|
|
|
220
248
|
Delegate to a child agent with a clean context: exploration fills the
|
|
@@ -345,6 +373,7 @@ policy = Mistri::RetryPolicy.new(attempts: 3)
|
|
|
345
373
|
|
|
346
374
|
```ruby
|
|
347
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
|
|
348
377
|
agent.run("What trend does this chart show?", images: [photo])
|
|
349
378
|
|
|
350
379
|
Mistri.agent("gpt-5.5", provider_options: { reasoning: { effort: "high" } })
|
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,6 +47,7 @@ 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
|
|
@@ -302,7 +304,8 @@ module Mistri
|
|
|
302
304
|
|
|
303
305
|
results = ToolExecutor.call(calls, @tools_by_name, signal: signal,
|
|
304
306
|
max_concurrency: @max_concurrency,
|
|
305
|
-
session: @session, emit: emit
|
|
307
|
+
session: @session, emit: emit,
|
|
308
|
+
app: @context)
|
|
306
309
|
context = hook_context(signal, emit)
|
|
307
310
|
results.each do |call, result, seconds|
|
|
308
311
|
result = rewrite(call, result, context) if @after_tool
|
|
@@ -337,7 +340,7 @@ module Mistri
|
|
|
337
340
|
end
|
|
338
341
|
|
|
339
342
|
def hook_context(signal, emit)
|
|
340
|
-
ToolContext.new(session: @session, signal: signal, emit: emit)
|
|
343
|
+
ToolContext.new(session: @session, signal: signal, emit: emit, app: @context)
|
|
341
344
|
end
|
|
342
345
|
|
|
343
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
|
|
@@ -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/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
|