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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 05f65dbeaf12ad0dc59334e300a2ecd2febc01607d528c0d8e7a95003c49e608
4
- data.tar.gz: f1c261e05ee6e433f3b08f6c129841b6b25a44ee935671d3314681de792d0da5
3
+ metadata.gz: de242bfa63b5f170166c93613fc2a2807971d45b792a6200eb60805c4f41c977
4
+ data.tar.gz: 362d523bba1848e2b552ece5906b1554c4a54820379c9f8e7a3e93430144a9c1
5
5
  SHA512:
6
- metadata.gz: 852c0bc7b87c7aa8b09b6862b88babbdbe3ddd6bd3e9736d8429818a3daaa98279fbe4f7703fd45e371ab78d7e76b0660ee19c47f183c760bcdba41d58bb6929
7
- data.tar.gz: 2e97b19dfee548042488c16d4d37ddcdf5adf3959de7cd90d076e29c667021f2c07aec603eb8d6d0316ce944a027b55da9ca13f2adf3bc1647dac38b16316c0c
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://codecov.io/gh/mcheemaa/mistri/graph/badge.svg"></a>
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
- send_gift = Mistri::Tool.define("send_gift", "Sends a real gift.",
114
- needs_approval: ->(args) { args["amount"].to_i > 100 }) do |args|
115
- Gifts.send!(args)
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("Send Ana a $200 gift")
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
@@ -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" then thinking_delta(record["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
- def thinking_delta(delta, &)
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"],
@@ -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, and the event stream. Handlers take
6
- # it as an optional second argument — a proc ignores it invisibly, a
7
- # lambda opts in by accepting two parameters. Sub-agents are built on it;
8
- # any tool that spawns work, links records to the session, or streams
9
- # progress can use it the same way.
10
- ToolContext = Data.define(:session, :signal, :emit) do
11
- def initialize(session: nil, signal: nil, emit: nil)
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
@@ -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] }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mistri
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
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.3.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