mistri 0.0.2 → 0.1.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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +177 -0
  3. data/NOTICE +9 -0
  4. data/README.md +314 -3
  5. data/lib/generators/mistri/install/install_generator.rb +54 -0
  6. data/lib/generators/mistri/install/templates/migration.rb.tt +14 -0
  7. data/lib/generators/mistri/install/templates/model.rb.tt +4 -0
  8. data/lib/mistri/abort_signal.rb +63 -0
  9. data/lib/mistri/agent.rb +340 -0
  10. data/lib/mistri/budget.rb +29 -0
  11. data/lib/mistri/compaction.rb +78 -0
  12. data/lib/mistri/compactor.rb +182 -0
  13. data/lib/mistri/content.rb +89 -0
  14. data/lib/mistri/edit.rb +238 -0
  15. data/lib/mistri/errors.rb +94 -0
  16. data/lib/mistri/event.rb +50 -0
  17. data/lib/mistri/memory.rb +26 -0
  18. data/lib/mistri/message.rb +90 -0
  19. data/lib/mistri/models.rb +43 -0
  20. data/lib/mistri/partial_json.rb +210 -0
  21. data/lib/mistri/providers/anthropic/assembler.rb +205 -0
  22. data/lib/mistri/providers/anthropic/serializer.rb +106 -0
  23. data/lib/mistri/providers/anthropic.rb +106 -0
  24. data/lib/mistri/providers/fake.rb +109 -0
  25. data/lib/mistri/providers/gemini/assembler.rb +163 -0
  26. data/lib/mistri/providers/gemini/serializer.rb +109 -0
  27. data/lib/mistri/providers/gemini.rb +73 -0
  28. data/lib/mistri/providers/openai/assembler.rb +205 -0
  29. data/lib/mistri/providers/openai/serializer.rb +104 -0
  30. data/lib/mistri/providers/openai.rb +72 -0
  31. data/lib/mistri/result.rb +30 -0
  32. data/lib/mistri/retry_policy.rb +47 -0
  33. data/lib/mistri/schema.rb +162 -0
  34. data/lib/mistri/session.rb +124 -0
  35. data/lib/mistri/sinks/action_cable.rb +30 -0
  36. data/lib/mistri/sinks/coalesced.rb +61 -0
  37. data/lib/mistri/sinks/sse.rb +26 -0
  38. data/lib/mistri/skill.rb +15 -0
  39. data/lib/mistri/skills.rb +81 -0
  40. data/lib/mistri/sse.rb +50 -0
  41. data/lib/mistri/stop_reason.rb +25 -0
  42. data/lib/mistri/stores/active_record.rb +47 -0
  43. data/lib/mistri/stores/jsonl.rb +37 -0
  44. data/lib/mistri/stores/memory.rb +22 -0
  45. data/lib/mistri/sub_agent.rb +211 -0
  46. data/lib/mistri/tool.rb +94 -0
  47. data/lib/mistri/tool_call.rb +18 -0
  48. data/lib/mistri/tool_context.rb +15 -0
  49. data/lib/mistri/tool_executor.rb +66 -0
  50. data/lib/mistri/tool_result.rb +23 -0
  51. data/lib/mistri/tools/edit_file.rb +37 -0
  52. data/lib/mistri/tools/find_in_file.rb +36 -0
  53. data/lib/mistri/tools/list_files.rb +16 -0
  54. data/lib/mistri/tools/read_file.rb +38 -0
  55. data/lib/mistri/tools/read_memory.rb +16 -0
  56. data/lib/mistri/tools/update_memory.rb +22 -0
  57. data/lib/mistri/tools/write_file.rb +20 -0
  58. data/lib/mistri/tools.rb +50 -0
  59. data/lib/mistri/transport.rb +187 -0
  60. data/lib/mistri/usage.rb +79 -0
  61. data/lib/mistri/version.rb +3 -1
  62. data/lib/mistri/workspace/active_record.rb +47 -0
  63. data/lib/mistri/workspace/directory.rb +52 -0
  64. data/lib/mistri/workspace/memory.rb +40 -0
  65. data/lib/mistri/workspace/single.rb +48 -0
  66. data/lib/mistri.rb +91 -2
  67. metadata +73 -7
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ # Loads skills and wires them into an agent: their one-line descriptions
5
+ # ride the system prompt, and the model pulls a full body on demand with
6
+ # the read_skill tool — so a large library costs almost nothing until a
7
+ # skill is actually used.
8
+ module Skills
9
+ module_function
10
+
11
+ # Reads a directory of skills in either layout: <dir>/<name>/SKILL.md
12
+ # or <dir>/<name>.md. Frontmatter (name:, description:) overrides the
13
+ # path-derived name.
14
+ def load(path)
15
+ raise ConfigurationError, "no skills directory at #{path}" unless File.directory?(path)
16
+
17
+ skills = Dir.children(path).sort.filter_map do |child|
18
+ full = File.join(path, child)
19
+ if File.directory?(full) && File.file?(File.join(full, "SKILL.md"))
20
+ read(File.join(full, "SKILL.md"), default_name: child)
21
+ elsif child.end_with?(".md") && File.file?(full)
22
+ read(full, default_name: File.basename(child, ".md"))
23
+ end
24
+ end
25
+ skills.sort_by(&:name)
26
+ end
27
+
28
+ def read(file, default_name:)
29
+ meta, body = parse(File.read(file))
30
+ Skill.new(name: meta.fetch("name", default_name),
31
+ description: meta.fetch("description", ""), body: body)
32
+ end
33
+
34
+ # The always-present cost of a skill library: one line per skill.
35
+ def section(skills)
36
+ lines = skills.map { |skill| "- #{skill.name}: #{skill.description}" }
37
+ <<~TEXT.strip
38
+ ## Skills
39
+
40
+ Expert playbooks. Before acting, check this list: when a skill
41
+ matches the task, you MUST call read_skill with its name and follow
42
+ the playbook.
43
+
44
+ #{lines.join("\n")}
45
+ TEXT
46
+ end
47
+
48
+ def amend(system, skills)
49
+ return system if skills.empty?
50
+
51
+ [system, section(skills)].compact.join("\n\n")
52
+ end
53
+
54
+ def reader(skills)
55
+ by_name = skills.to_h { |skill| [skill.name, skill] }
56
+ Tool.define("read_skill", "Reads the full playbook for a named skill.",
57
+ schema: -> { string :name, "Skill name", required: true }) do |args|
58
+ skill = by_name[args["name"]]
59
+ next skill.body if skill
60
+
61
+ "Unknown skill #{args["name"].inspect}. Available: #{by_name.keys.join(", ")}"
62
+ end
63
+ end
64
+
65
+ # Frontmatter is deliberately a subset: flat string keys between ---
66
+ # markers, quotes optional. name and description are the whole contract,
67
+ # and a YAML dependency is not worth two fields.
68
+ def parse(text)
69
+ return [{}, text] unless text.start_with?("---\n")
70
+
71
+ head, separator, body = text[4..].partition("\n---\n")
72
+ return [{}, text] if separator.empty?
73
+
74
+ meta = {}
75
+ head.scan(/^([a-z_]+):[ \t]*(.+?)[ \t]*$/) do |key, value|
76
+ meta[key] = value.gsub(/\A["']|["']\z/, "")
77
+ end
78
+ [meta, body.sub(/\A\n+/, "")]
79
+ end
80
+ end
81
+ end
data/lib/mistri/sse.rb ADDED
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Mistri
6
+ # An incremental Server-Sent Events decoder. Feed it raw socket fragments in
7
+ # any chunking; it buffers partial lines across fragments and yields each
8
+ # complete "data:" record as a parsed Hash.
9
+ #
10
+ # The decode is deliberately tolerant of what the provider APIs actually
11
+ # send: one single-line JSON object per "data:" record. "event:", "id:",
12
+ # comment, and blank lines are ignored (the event name is duplicated inside
13
+ # the data payload), OpenAI's "[DONE]" sentinel is dropped, and a record that
14
+ # fails to parse is skipped rather than killing a live stream.
15
+ class SSE
16
+ def initialize
17
+ @buffer = +""
18
+ end
19
+
20
+ def feed(fragment, &)
21
+ @buffer << fragment
22
+ while (newline = @buffer.index("\n"))
23
+ line = @buffer.slice!(0, newline + 1)
24
+ decode(line.chomp, &)
25
+ end
26
+ nil
27
+ end
28
+
29
+ # Flush a trailing record that arrived without a final newline.
30
+ def finish(&)
31
+ decode(@buffer.chomp, &) unless @buffer.empty?
32
+ @buffer.clear
33
+ nil
34
+ end
35
+
36
+ private
37
+
38
+ def decode(line)
39
+ return unless line.start_with?("data:")
40
+
41
+ data = line.delete_prefix("data:").strip
42
+ return if data.empty? || data == "[DONE]"
43
+
44
+ decoded = JSON.parse(data)
45
+ yield decoded if decoded.is_a?(Hash)
46
+ rescue JSON::ParserError
47
+ nil
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ # Why an assistant turn stopped. Providers map their native finish reasons
5
+ # onto this one set, so the loop and the host never read wire spellings.
6
+ #
7
+ # :stop - the model finished its answer
8
+ # :length - it hit the max-tokens ceiling mid-turn
9
+ # :tool_use - it paused to call one or more tools
10
+ # :error - the provider or runtime failed
11
+ # :aborted - the host cancelled the turn
12
+ # :budget - a configured ceiling stopped the run
13
+ module StopReason
14
+ STOP = :stop
15
+ LENGTH = :length
16
+ TOOL_USE = :tool_use
17
+ ERROR = :error
18
+ ABORTED = :aborted
19
+ BUDGET = :budget
20
+
21
+ ALL = [STOP, LENGTH, TOOL_USE, ERROR, ABORTED, BUDGET].freeze
22
+
23
+ def self.valid?(reason) = ALL.include?(reason)
24
+ end
25
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Mistri
6
+ module Stores
7
+ # Entries in the host's own database, through a model class the host
8
+ # supplies, so sessions live where application data lives: MySQL,
9
+ # Postgres, whatever the app runs. Not auto-required; load it with
10
+ # require "mistri/stores/active_record".
11
+ #
12
+ # The model needs three columns: session_id (string, indexed), position
13
+ # (integer), payload (text). A migration to copy:
14
+ #
15
+ # create_table :mistri_entries do |t|
16
+ # t.string :session_id, null: false, index: true
17
+ # t.integer :position, null: false
18
+ # t.text :payload, size: :medium, null: false
19
+ # t.timestamps
20
+ # end
21
+ # add_index :mistri_entries, [:session_id, :position], unique: true
22
+ #
23
+ # The unique index is load-bearing: one session has one writer at a time
24
+ # (the loop is serial), and if a host ever runs two agents on one session,
25
+ # a colliding append raises instead of silently corrupting entry order.
26
+ #
27
+ # Reads select only (position, payload) and sort in Ruby: ORDER BY over
28
+ # rows carrying multi-megabyte payloads exhausts MySQL's sort buffer.
29
+ class ActiveRecord
30
+ def initialize(model)
31
+ @model = model
32
+ end
33
+
34
+ def append(id, entry)
35
+ position = @model.where(session_id: id).maximum(:position).to_i + 1
36
+ @model.create!(session_id: id, position: position, payload: JSON.generate(entry))
37
+ nil
38
+ end
39
+
40
+ def load(id)
41
+ @model.where(session_id: id).pluck(:position, :payload)
42
+ .sort_by(&:first)
43
+ .map { |_position, payload| JSON.parse(payload) }
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Mistri
7
+ module Stores
8
+ # One JSONL file per session under a directory: durable sessions without
9
+ # a database, and a transcript any tool can read line by line.
10
+ class JSONL
11
+ def initialize(dir)
12
+ @dir = dir
13
+ FileUtils.mkdir_p(dir)
14
+ end
15
+
16
+ def append(id, entry)
17
+ File.open(path(id), "a") { |file| file.puts(JSON.generate(entry)) }
18
+ nil
19
+ end
20
+
21
+ def load(id)
22
+ return [] unless File.exist?(path(id))
23
+
24
+ File.readlines(path(id)).filter_map do |line|
25
+ JSON.parse(line) unless line.strip.empty?
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ # Session ids become filenames; anything path-hostile is replaced.
32
+ def path(id)
33
+ File.join(@dir, "#{id.to_s.gsub(/[^a-zA-Z0-9_-]/, "-")}.jsonl")
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ module Stores
5
+ # Entries in a plain Hash: for tests and runs that need no persistence.
6
+ class Memory
7
+ def initialize
8
+ @entries = Hash.new { |hash, key| hash[key] = [] }
9
+ @mutex = Mutex.new
10
+ end
11
+
12
+ def append(id, entry)
13
+ @mutex.synchronize { @entries[id] << entry }
14
+ nil
15
+ end
16
+
17
+ def load(id)
18
+ @mutex.synchronize { @entries[id].dup }
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ # Delegation with a clean context: a child agent runs on its own session
5
+ # (the caller's store, linked in the caller's transcript), and only its
6
+ # final answer returns to the parent — exploration never fills the
7
+ # parent's window. Compaction rescues a full context after the fact;
8
+ # spawning avoids filling it in the first place. A child session is its
9
+ # own single-provider session, so delegating to a cheaper model is the
10
+ # sanctioned way to mix models.
11
+ #
12
+ # Two shapes on one mechanism. A named specialist the host curates:
13
+ #
14
+ # researcher = Mistri::SubAgent.new(
15
+ # name: "researcher", description: "Answers factual questions.",
16
+ # provider: Mistri.provider("claude-haiku-4-5-20251001"),
17
+ # system: "Research. Report findings only.", tools: [fetch_page],
18
+ # )
19
+ # agent = Mistri::Agent.new(provider:, tools: [researcher.tool])
20
+ #
21
+ # and the open spawn tool, where the model writes the child's
22
+ # instructions, picks a tool subset, and may name a model:
23
+ #
24
+ # spawn = Mistri::SubAgent.spawner(provider:, tools: [fetch_page, search])
25
+ #
26
+ # Children never receive a spawn tool: delegation is one level deep by
27
+ # construction. Parallel fan-out costs nothing — several spawn calls in
28
+ # one turn run concurrently on the executor pool.
29
+ #
30
+ # Child events forward into the parent's stream tagged with origin
31
+ # ("researcher#ab12cd34"; nesting of named specialists joins with ">").
32
+ # Approval-gated tools cannot ride inside a child: a child answers its
33
+ # parent synchronously and cannot fire-and-forget suspend, so statically
34
+ # gated tools are refused at construction and a child that suspends at
35
+ # runtime is denied and reported in band. Gate the delegation itself
36
+ # instead (needs_approval: on the definition or spawner).
37
+ class SubAgent
38
+ SPAWNER_DESCRIPTION =
39
+ "Delegate a self-contained task to a focused child agent with a clean " \
40
+ "context. The child starts blank: give it complete instructions and " \
41
+ "every fact it needs. Use it to keep exploration out of your own " \
42
+ "context, and spawn several in one turn to fan out independent " \
43
+ "angles. Only the child's final answer comes back."
44
+
45
+ attr_reader :name, :description
46
+
47
+ # schema: makes the specialist answer in validated JSON (task mode
48
+ # underneath) — fan-out children then return a uniform shape the parent
49
+ # synthesizes instead of five styles of prose.
50
+ def initialize(name:, description:, provider:, system: nil, tools: [], schema: nil,
51
+ **agent_options)
52
+ SubAgent.forbid_gated!(tools)
53
+ @name = name.to_s
54
+ @description = description
55
+ @provider = provider
56
+ @system = system
57
+ @tools = tools
58
+ @schema = schema
59
+ @agent_options = agent_options
60
+ @gate = agent_options.delete(:needs_approval) || false
61
+ end
62
+
63
+ # The delegate tool: each call runs a fresh child and answers with its
64
+ # final text, plus {agent, session_id} on the ui channel so a host can
65
+ # link the child's transcript.
66
+ def tool
67
+ sub = self
68
+ blurb = "#{@description} Runs as a focused sub-agent with a clean " \
69
+ "context: give it complete instructions, it starts blank."
70
+ Tool.define(@name, blurb, needs_approval: @gate,
71
+ schema: lambda {
72
+ string :task, "Complete instructions for the sub-agent",
73
+ required: true
74
+ }) do |args, context|
75
+ sub.run_child(args.fetch("task"), context)
76
+ end
77
+ end
78
+
79
+ def run_child(task, context)
80
+ SubAgent.run_child(label: @name, provider: @provider, system: @system,
81
+ tools: @tools, task: task, context: context, schema: @schema,
82
+ **@agent_options)
83
+ end
84
+
85
+ class << self
86
+ # The open spawn tool over a pool of tools the host allows children to
87
+ # use. The model may grant a tool subset by name; models: is the
88
+ # host's allowlist of child model ids — without one, no model choice
89
+ # is offered at all, so a hallucinated id can never construct a
90
+ # provider or land children on an expensive model.
91
+ def spawner(provider:, tools: [], models: [], needs_approval: false, **agent_options)
92
+ forbid_gated!(tools)
93
+ if tools.any? { |tool| tool.name == "spawn_agent" }
94
+ raise ConfigurationError, "the spawn tool never goes in its own pool"
95
+ end
96
+
97
+ Tool.define("spawn_agent", SPAWNER_DESCRIPTION,
98
+ needs_approval: needs_approval,
99
+ schema: spawner_schema(tools, models)) do |args, context|
100
+ run_child(label: "spawn", provider: child_provider(provider, args["model"], models),
101
+ system: args.fetch("instructions"),
102
+ tools: pick(tools, args["tools"]),
103
+ task: args.fetch("task"), context: context, **agent_options)
104
+ end
105
+ end
106
+
107
+ def run_child(label:, provider:, system:, tools:, task:, context:, schema: nil,
108
+ **agent_options)
109
+ store = context.session ? context.session.store : Stores::Memory.new
110
+ child = Session.new(store: store)
111
+ context.session&.append("subagent", "name" => label, "session_id" => child.id)
112
+ agent = Agent.new(provider: provider, session: child, system: system,
113
+ tools: tools, **agent_options)
114
+ origin = "#{label}##{child.id[0, 8]}"
115
+ emit = ->(event) { forward(event, origin, context) }
116
+ result = if schema
117
+ agent.task(task, schema: schema, signal: context.signal, &emit)
118
+ else
119
+ agent.run(task, signal: context.signal, &emit)
120
+ end
121
+ answer(result, label, child)
122
+ end
123
+
124
+ def forbid_gated!(tools)
125
+ gated = tools.select { |tool| statically_gated?(tool) }
126
+ return if gated.empty?
127
+
128
+ raise ConfigurationError,
129
+ "approval-gated tools cannot run inside a sub-agent " \
130
+ "(#{gated.map(&:name).join(", ")}); gate the delegation instead"
131
+ end
132
+
133
+ private
134
+
135
+ def forward(event, origin, context)
136
+ return unless context.emit
137
+
138
+ tagged = event.origin ? "#{origin}>#{event.origin}" : origin
139
+ context.emit.call(event.with(origin: tagged))
140
+ end
141
+
142
+ # The parent always gets an in-band answer it can react to. A child
143
+ # that suspends for approval is denied and abandoned: nothing else
144
+ # will ever settle it.
145
+ def answer(result, label, child)
146
+ link = { "agent" => label, "session_id" => child.id }
147
+ case result.status
148
+ when :completed
149
+ ToolResult.new(content: result.text.to_s, ui: link)
150
+ when :awaiting_approval
151
+ result.pending.each do |call|
152
+ child.deny(call.id, note: "sub-agents cannot pause for human approval")
153
+ end
154
+ ToolResult.new(content: "The #{label} sub-agent stopped: it needed human " \
155
+ "approval, which sub-agents cannot wait for.", ui: link)
156
+ when :aborted
157
+ ToolResult.new(content: "[the #{label} sub-agent was aborted]", ui: link)
158
+ else
159
+ reason = result.error_message || result.status
160
+ ToolResult.new(content: "The #{label} sub-agent failed: #{reason}", ui: link)
161
+ end
162
+ end
163
+
164
+ def pick(pool, names)
165
+ return pool if names.nil? || names.empty?
166
+
167
+ by_name = pool.to_h { |tool| [tool.name, tool] }
168
+ names.map do |name|
169
+ by_name.fetch(name) do
170
+ raise ArgumentError,
171
+ "unknown tool #{name.inspect}; available: #{by_name.keys.join(", ")}"
172
+ end
173
+ end
174
+ end
175
+
176
+ def spawner_schema(pool, models)
177
+ tool_names = pool.map(&:name)
178
+ lambda do
179
+ string :task, "The child's complete task", required: true
180
+ string :instructions, "The child's system prompt", required: true
181
+ if tool_names.any?
182
+ array :tools, "Subset of tools to grant (default: all)",
183
+ items: { type: "string", enum: tool_names }
184
+ end
185
+ if models.any?
186
+ string :model, "Model for the child (default: the configured one)",
187
+ enum: models
188
+ end
189
+ end
190
+ end
191
+
192
+ def child_provider(default, requested, models)
193
+ return default if requested.nil? || requested.to_s.empty?
194
+ unless models.include?(requested)
195
+ raise ArgumentError,
196
+ "model #{requested.inspect} is not allowed; available: #{models.join(", ")}"
197
+ end
198
+
199
+ Mistri.provider(requested)
200
+ end
201
+
202
+ # A predicate gate cannot be judged statically; the runtime denial in
203
+ # answer covers it.
204
+ def statically_gated?(tool)
205
+ tool.needs_approval?({})
206
+ rescue StandardError
207
+ false
208
+ end
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Mistri
6
+ # A tool the agent can call: a name, a description, a JSON Schema for its
7
+ # arguments, and a handler. The handler receives the parsed arguments hash
8
+ # (string keys, exactly as the model sent them) and returns a String, a Hash
9
+ # (serialized as JSON), or content blocks, so a tool can hand back images as
10
+ # naturally as text.
11
+ class Tool
12
+ # A no-argument tool still needs a valid object schema; providers reject a
13
+ # bare empty hash.
14
+ EMPTY_SCHEMA = { type: "object", properties: {} }.freeze
15
+
16
+ attr_reader :name, :description, :input_schema
17
+
18
+ # Define a tool. Give the argument shape as a raw JSON Schema hash via
19
+ # input_schema:, or build it in Ruby with a schema: block.
20
+ #
21
+ # Tool.define("get_weather", "Weather for a city",
22
+ # schema: -> { string :city, "City name", required: true }) do |args|
23
+ # Weather.for(args["city"])
24
+ # end
25
+ def self.define(name, description, input_schema: nil, schema: nil, **, &handler)
26
+ input_schema ||= schema ? Schema.build(&schema) : EMPTY_SCHEMA
27
+ new(name: name, description: description, input_schema: input_schema, **, &handler)
28
+ end
29
+
30
+ def initialize(name:, description:, input_schema: EMPTY_SCHEMA, eager_input_streaming: false,
31
+ needs_approval: false, &handler)
32
+ raise ArgumentError, "tool #{name.inspect} needs a handler block" unless handler
33
+
34
+ @name = name.to_s
35
+ @description = description
36
+ @input_schema = input_schema
37
+ @eager_input_streaming = eager_input_streaming
38
+ @needs_approval = needs_approval
39
+ @handler = handler
40
+ end
41
+
42
+ # A handler may return a ToolResult to speak on two channels; its ui
43
+ # payload is canonicalized through JSON here so the live event and a
44
+ # reloaded session read the identical shape.
45
+ #
46
+ # Handlers receive (arguments, context). A proc that declares one
47
+ # parameter ignores the context invisibly; a lambda opts in by arity.
48
+ def call(arguments, context = ToolContext.new)
49
+ result = invoke(arguments || {}, context)
50
+ return serialize_result(result) unless result.is_a?(ToolResult)
51
+
52
+ result.with(content: serialize_result(result.content),
53
+ ui: result.ui && JSON.parse(JSON.generate(result.ui)))
54
+ end
55
+
56
+ # Whether this call should pause for a human. true/false, or a callable
57
+ # given the parsed arguments so a tool can gate only the risky calls
58
+ # (needs_approval: ->(args) { args["amount"].to_i > 100 }).
59
+ def needs_approval?(arguments)
60
+ @needs_approval.respond_to?(:call) ? @needs_approval.call(arguments) : @needs_approval
61
+ end
62
+
63
+ # The provider-facing definition; every serializer accepts this shape.
64
+ def spec
65
+ definition = { name: @name, description: @description, input_schema: @input_schema }
66
+ definition[:eager_input_streaming] = true if @eager_input_streaming
67
+ definition
68
+ end
69
+
70
+ private
71
+
72
+ def invoke(arguments, context)
73
+ if @handler.lambda? && @handler.arity.between?(0, 1)
74
+ @handler.arity.zero? ? @handler.call : @handler.call(arguments)
75
+ else
76
+ @handler.call(arguments, context)
77
+ end
78
+ end
79
+
80
+ # Content blocks pass through so tools can return images; everything else
81
+ # the model reads as text, with structured data as JSON, never as Ruby
82
+ # inspect output.
83
+ def serialize_result(result)
84
+ case result
85
+ when String then result
86
+ when nil then ""
87
+ when Array
88
+ content = result.all? { |element| element.respond_to?(:type) || element.is_a?(String) }
89
+ content ? result : JSON.generate(result)
90
+ else result.respond_to?(:type) ? result : JSON.generate(result)
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ # One tool invocation requested by the model. `arguments` is the parsed Hash
5
+ # exactly as the model emitted it, string keys and all; the top level is owned
6
+ # and frozen, nested values stay as parsed. `signature` is the opaque
7
+ # reasoning payload some providers attach to the call and require echoed back,
8
+ # such as Gemini's thoughtSignature.
9
+ ToolCall = Data.define(:id, :name, :arguments, :signature) do
10
+ def initialize(id:, name:, arguments: {}, signature: nil)
11
+ super(id:, name:, arguments: arguments.dup.freeze, signature:)
12
+ end
13
+
14
+ def type = :tool_call
15
+
16
+ def to_h = { type: :tool_call, id:, name:, arguments:, signature: }.compact
17
+ end
18
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
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)
12
+ super
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ # Runs a turn's tool calls and returns their results in the order the model
5
+ # emitted them, regardless of completion order. Independent calls run
6
+ # concurrently up to max_concurrency; each runs inside the Rails executor
7
+ # when Rails is present, so ActiveRecord connections return to the pool.
8
+ #
9
+ # A tool that raises becomes an in-band error string the model can read. An
10
+ # abort never starts a not-yet-started call: it gets an interrupted result
11
+ # instead, so the turn always pairs and the session replays cleanly.
12
+ module ToolExecutor
13
+ INTERRUPTED = "[interrupted: this tool call never ran]"
14
+
15
+ module_function
16
+
17
+ def call(calls, tools_by_name, signal: nil, max_concurrency: 4, session: nil, emit: nil)
18
+ return [] if calls.empty?
19
+
20
+ context = ToolContext.new(session: session, signal: signal, emit: thread_safe(emit))
21
+ results = Array.new(calls.length)
22
+ queue = Queue.new
23
+ calls.each_with_index { |call, index| queue << [call, index] }
24
+ workers = max_concurrency.clamp(1, calls.length)
25
+ Array.new(workers) { worker(queue, results, tools_by_name, context) }.each(&:join)
26
+ calls.zip(results).map { |call, result| [call, result || INTERRUPTED] }
27
+ end
28
+
29
+ def worker(queue, results, tools_by_name, context)
30
+ Thread.new do
31
+ loop do
32
+ call, index = begin
33
+ queue.pop(true)
34
+ rescue ThreadError
35
+ break
36
+ end
37
+ interrupted = context.signal&.aborted?
38
+ results[index] = interrupted ? INTERRUPTED : run_one(call, tools_by_name, context)
39
+ end
40
+ end
41
+ end
42
+
43
+ def run_one(call, tools_by_name, context)
44
+ tool = tools_by_name[call.name]
45
+ return "Error: unknown tool #{call.name.inspect}" unless tool
46
+
47
+ with_rails_executor { tool.call(call.arguments, context) }
48
+ rescue StandardError => e
49
+ "Error running tool #{call.name.inspect}: #{e.class}: #{e.message}"
50
+ end
51
+
52
+ # Concurrent tools share the caller's sink; sinks are not required to be
53
+ # thread-safe, so forwarded events serialize here.
54
+ def thread_safe(emit)
55
+ return nil unless emit
56
+
57
+ mutex = Mutex.new
58
+ ->(event) { mutex.synchronize { emit.call(event) } }
59
+ end
60
+
61
+ def with_rails_executor(&)
62
+ executor = defined?(Rails) && Rails.respond_to?(:application) && Rails.application&.executor
63
+ executor ? executor.wrap(&) : yield
64
+ end
65
+ end
66
+ end