mistri 0.0.3 → 0.2.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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +215 -0
  3. data/README.md +367 -3
  4. data/lib/generators/mistri/install/install_generator.rb +54 -0
  5. data/lib/generators/mistri/install/templates/migration.rb.tt +14 -0
  6. data/lib/generators/mistri/install/templates/model.rb.tt +4 -0
  7. data/lib/generators/mistri/mcp/mcp_generator.rb +57 -0
  8. data/lib/generators/mistri/mcp/templates/migration.rb.tt +27 -0
  9. data/lib/generators/mistri/mcp/templates/model.rb.tt +63 -0
  10. data/lib/mistri/abort_signal.rb +63 -0
  11. data/lib/mistri/agent.rb +389 -0
  12. data/lib/mistri/budget.rb +29 -0
  13. data/lib/mistri/compaction.rb +78 -0
  14. data/lib/mistri/compactor.rb +182 -0
  15. data/lib/mistri/content.rb +89 -0
  16. data/lib/mistri/edit.rb +238 -0
  17. data/lib/mistri/errors.rb +94 -0
  18. data/lib/mistri/event.rb +54 -0
  19. data/lib/mistri/mcp/client.rb +156 -0
  20. data/lib/mistri/mcp/oauth.rb +286 -0
  21. data/lib/mistri/mcp/wires.rb +164 -0
  22. data/lib/mistri/mcp.rb +96 -0
  23. data/lib/mistri/memory.rb +26 -0
  24. data/lib/mistri/message.rb +90 -0
  25. data/lib/mistri/models.rb +43 -0
  26. data/lib/mistri/partial_json.rb +210 -0
  27. data/lib/mistri/providers/anthropic/assembler.rb +205 -0
  28. data/lib/mistri/providers/anthropic/serializer.rb +106 -0
  29. data/lib/mistri/providers/anthropic.rb +106 -0
  30. data/lib/mistri/providers/fake.rb +109 -0
  31. data/lib/mistri/providers/gemini/assembler.rb +163 -0
  32. data/lib/mistri/providers/gemini/serializer.rb +109 -0
  33. data/lib/mistri/providers/gemini.rb +73 -0
  34. data/lib/mistri/providers/openai/assembler.rb +205 -0
  35. data/lib/mistri/providers/openai/serializer.rb +104 -0
  36. data/lib/mistri/providers/openai.rb +72 -0
  37. data/lib/mistri/reminder.rb +36 -0
  38. data/lib/mistri/result.rb +32 -0
  39. data/lib/mistri/retry_policy.rb +47 -0
  40. data/lib/mistri/schema.rb +162 -0
  41. data/lib/mistri/session.rb +124 -0
  42. data/lib/mistri/sinks/action_cable.rb +30 -0
  43. data/lib/mistri/sinks/coalesced.rb +61 -0
  44. data/lib/mistri/sinks/sse.rb +26 -0
  45. data/lib/mistri/skill.rb +15 -0
  46. data/lib/mistri/skills.rb +81 -0
  47. data/lib/mistri/sse.rb +50 -0
  48. data/lib/mistri/stop_reason.rb +25 -0
  49. data/lib/mistri/stores/active_record.rb +47 -0
  50. data/lib/mistri/stores/jsonl.rb +37 -0
  51. data/lib/mistri/stores/memory.rb +22 -0
  52. data/lib/mistri/sub_agent.rb +211 -0
  53. data/lib/mistri/tool.rb +95 -0
  54. data/lib/mistri/tool_call.rb +18 -0
  55. data/lib/mistri/tool_context.rb +15 -0
  56. data/lib/mistri/tool_executor.rb +87 -0
  57. data/lib/mistri/tool_result.rb +23 -0
  58. data/lib/mistri/tools/edit_file.rb +37 -0
  59. data/lib/mistri/tools/find_in_file.rb +36 -0
  60. data/lib/mistri/tools/list_files.rb +16 -0
  61. data/lib/mistri/tools/read_file.rb +38 -0
  62. data/lib/mistri/tools/read_memory.rb +16 -0
  63. data/lib/mistri/tools/update_memory.rb +22 -0
  64. data/lib/mistri/tools/write_file.rb +20 -0
  65. data/lib/mistri/tools.rb +50 -0
  66. data/lib/mistri/transport.rb +228 -0
  67. data/lib/mistri/usage.rb +79 -0
  68. data/lib/mistri/version.rb +1 -1
  69. data/lib/mistri/workspace/active_record.rb +47 -0
  70. data/lib/mistri/workspace/directory.rb +52 -0
  71. data/lib/mistri/workspace/memory.rb +40 -0
  72. data/lib/mistri/workspace/single.rb +48 -0
  73. data/lib/mistri.rb +89 -0
  74. metadata +79 -10
@@ -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,95 @@
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, :timeout
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, timeout: nil, &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
+ @timeout = timeout
40
+ @handler = handler
41
+ end
42
+
43
+ # A handler may return a ToolResult to speak on two channels; its ui
44
+ # payload is canonicalized through JSON here so the live event and a
45
+ # reloaded session read the identical shape.
46
+ #
47
+ # Handlers receive (arguments, context). A proc that declares one
48
+ # parameter ignores the context invisibly; a lambda opts in by arity.
49
+ def call(arguments, context = ToolContext.new)
50
+ result = invoke(arguments || {}, context)
51
+ return serialize_result(result) unless result.is_a?(ToolResult)
52
+
53
+ result.with(content: serialize_result(result.content),
54
+ ui: result.ui && JSON.parse(JSON.generate(result.ui)))
55
+ end
56
+
57
+ # Whether this call should pause for a human. true/false, or a callable
58
+ # given the parsed arguments so a tool can gate only the risky calls
59
+ # (needs_approval: ->(args) { args["amount"].to_i > 100 }).
60
+ def needs_approval?(arguments)
61
+ @needs_approval.respond_to?(:call) ? @needs_approval.call(arguments) : @needs_approval
62
+ end
63
+
64
+ # The provider-facing definition; every serializer accepts this shape.
65
+ def spec
66
+ definition = { name: @name, description: @description, input_schema: @input_schema }
67
+ definition[:eager_input_streaming] = true if @eager_input_streaming
68
+ definition
69
+ end
70
+
71
+ private
72
+
73
+ def invoke(arguments, context)
74
+ if @handler.lambda? && @handler.arity.between?(0, 1)
75
+ @handler.arity.zero? ? @handler.call : @handler.call(arguments)
76
+ else
77
+ @handler.call(arguments, context)
78
+ end
79
+ end
80
+
81
+ # Content blocks pass through so tools can return images; everything else
82
+ # the model reads as text, with structured data as JSON, never as Ruby
83
+ # inspect output.
84
+ def serialize_result(result)
85
+ case result
86
+ when String then result
87
+ when nil then ""
88
+ when Array
89
+ content = result.all? { |element| element.respond_to?(:type) || element.is_a?(String) }
90
+ content ? result : JSON.generate(result)
91
+ else result.respond_to?(:type) ? result : JSON.generate(result)
92
+ end
93
+ end
94
+ end
95
+ 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,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ module Mistri
6
+ # Runs a turn's tool calls and returns their results in the order the model
7
+ # emitted them, regardless of completion order. Independent calls run
8
+ # concurrently up to max_concurrency; each runs inside the Rails executor
9
+ # when Rails is present, so ActiveRecord connections return to the pool.
10
+ #
11
+ # A tool that raises becomes an in-band error string the model can read. An
12
+ # abort never starts a not-yet-started call: it gets an interrupted result
13
+ # instead, so the turn always pairs and the session replays cleanly.
14
+ module ToolExecutor
15
+ INTERRUPTED = "[interrupted: this tool call never ran]"
16
+
17
+ module_function
18
+
19
+ def call(calls, tools_by_name, signal: nil, max_concurrency: 4, session: nil, emit: nil)
20
+ return [] if calls.empty?
21
+
22
+ context = ToolContext.new(session: session, signal: signal, emit: thread_safe(emit))
23
+ results = Array.new(calls.length)
24
+ queue = Queue.new
25
+ calls.each_with_index { |call, index| queue << [call, index] }
26
+ workers = max_concurrency.clamp(1, calls.length)
27
+ Array.new(workers) { worker(queue, results, tools_by_name, context) }.each(&:join)
28
+ calls.zip(results).map do |call, entry|
29
+ value, seconds = entry || [INTERRUPTED, nil]
30
+ [call, value, seconds]
31
+ end
32
+ end
33
+
34
+ def worker(queue, results, tools_by_name, context)
35
+ Thread.new do
36
+ loop do
37
+ call, index = begin
38
+ queue.pop(true)
39
+ rescue ThreadError
40
+ break
41
+ end
42
+ if context.signal&.aborted?
43
+ results[index] = [INTERRUPTED, nil]
44
+ next
45
+ end
46
+
47
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
48
+ value = run_one(call, tools_by_name, context)
49
+ results[index] = [value, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started]
50
+ end
51
+ end
52
+ end
53
+
54
+ def run_one(call, tools_by_name, context)
55
+ tool = tools_by_name[call.name]
56
+ return "Error: unknown tool #{call.name.inspect}" unless tool
57
+
58
+ with_rails_executor { invoke(tool, call, context) }
59
+ rescue StandardError => e
60
+ "Error running tool #{call.name.inspect}: #{e.class}: #{e.message}"
61
+ end
62
+
63
+ # A tool with a timeout answers in band when it stalls, so one hung
64
+ # handler cannot stall the whole run.
65
+ def invoke(tool, call, context)
66
+ return tool.call(call.arguments, context) unless tool.timeout
67
+
68
+ Timeout.timeout(tool.timeout) { tool.call(call.arguments, context) }
69
+ rescue Timeout::Error
70
+ "Error running tool #{call.name.inspect}: timed out after #{tool.timeout}s"
71
+ end
72
+
73
+ # Concurrent tools share the caller's sink; sinks are not required to be
74
+ # thread-safe, so forwarded events serialize here.
75
+ def thread_safe(emit)
76
+ return nil unless emit
77
+
78
+ mutex = Mutex.new
79
+ ->(event) { mutex.synchronize { emit.call(event) } }
80
+ end
81
+
82
+ def with_rails_executor(&)
83
+ executor = defined?(Rails) && Rails.respond_to?(:application) && Rails.application&.executor
84
+ executor ? executor.wrap(&) : yield
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ # A two-channel tool result: content goes to the model, ui goes only to the
5
+ # host. The ui payload rides the tool message and its :tool_result event,
6
+ # persists with the session for transcript re-renders, and never reaches a
7
+ # provider. Return one from a handler when the UI needs more than the model
8
+ # should read or pay for: full query rows behind a compact answer, the
9
+ # updated document behind "saved".
10
+ #
11
+ # Tool.define("edit_page", "Edits the page.") do |args|
12
+ # page = apply(args)
13
+ # Mistri::ToolResult.new(content: "Updated.", ui: { "html" => page })
14
+ # end
15
+ #
16
+ # ui must be JSON-serializable; it is stored and delivered in canonical
17
+ # JSON form (string keys), the same shape a reloaded session reads.
18
+ ToolResult = Data.define(:content, :ui) do
19
+ def initialize(content:, ui: nil)
20
+ super
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ module Tools
5
+ module_function
6
+
7
+ # The model-facing shape is flat {path, old_string, new_string,
8
+ # replace_all} on purpose: it is the shape frontier models are trained on,
9
+ # and nested edit arrays measurably degrade their calls. Failures come
10
+ # back in band with the closest region and its exact difference, so the
11
+ # model's retry is one shot.
12
+ def edit_file(workspace)
13
+ Tool.define("edit_file",
14
+ "Replace an exact snippet of a document. Copy old_string verbatim from " \
15
+ "read_file output including whitespace, without line-number prefixes. " \
16
+ "It must match exactly one place; add surrounding lines to make it " \
17
+ "unique, or set replace_all to change every occurrence.",
18
+ eager_input_streaming: true,
19
+ schema: lambda {
20
+ string :path, "Document path", required: true
21
+ string :old_string, "Exact text to replace (whitespace matters)", required: true
22
+ string :new_string, "Replacement text", required: true
23
+ boolean :replace_all, "Replace every occurrence instead of exactly one"
24
+ }) do |args|
25
+ args = Tools.tolerate(args)
26
+ with_document(workspace, args) do |content|
27
+ result = Edit.replace(content, args["old_string"], args["new_string"],
28
+ replace_all: args["replace_all"] == true)
29
+ workspace.write(args["path"], result.content)
30
+ "Replaced #{result.count} occurrence(s) in #{args["path"]}"
31
+ end
32
+ rescue EditError => e
33
+ "edit_file failed: #{e.message}"
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ module Tools
5
+ module_function
6
+
7
+ def find_in_file(workspace)
8
+ Tool.define("find_in_file",
9
+ "Find text in a document. Returns line-numbered matches with context, " \
10
+ "so you can locate a region without reading the whole document.",
11
+ schema: lambda {
12
+ string :path, "Document path", required: true
13
+ string :query, "Text to find (plain substring)", required: true
14
+ integer :context, "Context lines around each match"
15
+ }) do |args|
16
+ with_document(workspace, args) do |content|
17
+ Tools.find_matches(content, args["query"], (args["context"] || 2).to_i)
18
+ end
19
+ end
20
+ end
21
+
22
+ def find_matches(content, query, context)
23
+ lines = content.lines
24
+ hits = lines.each_index.select { |i| lines[i].include?(query) }
25
+ return "No matches for #{query.inspect}." if hits.empty?
26
+
27
+ blocks = hits.first(20).map do |hit|
28
+ from = [hit - context, 0].max
29
+ to = [hit + context, lines.length - 1].min
30
+ (from..to).map { |n| "#{n + 1}: #{lines[n]}" }.join
31
+ end
32
+ notice = hits.length > 20 ? "\n[#{hits.length - 20} more matches not shown]" : ""
33
+ "#{blocks.join("---\n")}#{notice}"
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ module Tools
5
+ module_function
6
+
7
+ def list_files(workspace)
8
+ Tool.define("list_files",
9
+ "List document paths in the workspace, optionally under a prefix.",
10
+ schema: -> { string :prefix, "Only paths starting with this" }) do |args|
11
+ paths = workspace.list(args["prefix"])
12
+ paths.empty? ? "No documents found." : paths.join("\n")
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ module Tools
5
+ MAX_READ_CHARS = 20_000
6
+
7
+ module_function
8
+
9
+ def read_file(workspace)
10
+ Tool.define("read_file",
11
+ "Read a document with line numbers. Use offset and limit for a window " \
12
+ "into a long document.",
13
+ schema: lambda {
14
+ string :path, "Document path", required: true
15
+ integer :offset, "First line to read (1-based)"
16
+ integer :limit, "How many lines to read"
17
+ }) do |args|
18
+ with_document(workspace, args) do |content|
19
+ Tools.numbered_window(content, args["offset"], args["limit"])
20
+ end
21
+ end
22
+ end
23
+
24
+ def numbered_window(content, offset, limit)
25
+ lines = content.lines
26
+ from = [(offset || 1).to_i, 1].max
27
+ to = limit ? [from + limit.to_i - 1, lines.length].min : lines.length
28
+ numbered = (from..to).map { |n| "#{n}: #{lines[n - 1]}" }.join
29
+ windowed = to < lines.length || from > 1
30
+ suffix = windowed ? "\n[showing lines #{from}-#{to} of #{lines.length}]" : ""
31
+ return "#{numbered}#{suffix}" if numbered.length <= MAX_READ_CHARS
32
+
33
+ cut = numbered[0, MAX_READ_CHARS]
34
+ cut = cut[0..(cut.rindex("\n") || -1)]
35
+ "#{cut}\n[truncated at #{MAX_READ_CHARS} chars; use offset/limit to read more]"
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ module Tools
5
+ module_function
6
+
7
+ def read_memory(memory)
8
+ Tool.define("read_memory",
9
+ "Read the durable memory: knowledge kept across sessions. Check it " \
10
+ "before starting work that earlier sessions may have learned about.") do |_args|
11
+ content = memory.read
12
+ content.empty? ? "Memory is empty." : content
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ module Tools
5
+ module_function
6
+
7
+ # Whole-document replace on purpose: the model rewrites memory as one
8
+ # coherent text instead of appending fragments that drift.
9
+ def update_memory(memory)
10
+ Tool.define("update_memory",
11
+ "Replace the durable memory with an updated version. Pass the FULL " \
12
+ "text: what you were given plus what you learned, rewritten to stay " \
13
+ "short and current.",
14
+ schema: lambda {
15
+ string :content, "The complete new memory text", required: true
16
+ }) do |args|
17
+ memory.replace(args["content"])
18
+ "Memory updated (#{args["content"].to_s.length} chars)."
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mistri
4
+ module Tools
5
+ module_function
6
+
7
+ def write_file(workspace)
8
+ Tool.define("write_file",
9
+ "Create or fully overwrite a document with the given content.",
10
+ eager_input_streaming: true,
11
+ schema: lambda {
12
+ string :path, "Document path", required: true
13
+ string :content, "The full document content", required: true
14
+ }) do |args|
15
+ workspace.write(args["path"], args["content"])
16
+ "Wrote #{args["path"]} (#{args["content"].to_s.length} chars)"
17
+ end
18
+ end
19
+ end
20
+ end