Module: Mistri::Tools
- Defined in:
- lib/mistri/tools.rb,
lib/mistri/tools/edit_file.rb,
lib/mistri/tools/read_file.rb,
lib/mistri/tools/list_files.rb,
lib/mistri/tools/write_file.rb,
lib/mistri/tools/read_memory.rb,
lib/mistri/tools/find_in_file.rb,
lib/mistri/tools/update_memory.rb
Overview
Built-in tools. Tools.files binds the document tools to a workspace, so "file" means whatever the workspace says it means: a database column, a row in a documents table, or an actual file. The names stay read_file and edit_file because those are the tool names models are trained on.
Constant Summary collapse
- ALIASES =
{ "oldText" => "old_string", "old" => "old_string", "search" => "old_string", "newText" => "new_string", "new" => "new_string", "replace" => "new_string", "replaceAll" => "replace_all", "file" => "path", "filename" => "path" }.freeze
- MAX_READ_CHARS =
20_000
Class Method Summary collapse
-
.atomic_workspace?(workspace) ⇒ Boolean
Atomic writes are an explicit backend claim.
-
.edit_file(workspace) ⇒ Object
The model-facing shape is flat old_string, new_string, replace_all on purpose: it is the shape frontier models are trained on, and nested edit arrays measurably degrade their calls.
- .files(workspace) ⇒ Object
- .find_in_file(workspace) ⇒ Object
- .find_matches(content, query, context) ⇒ Object
- .list_files(workspace) ⇒ Object
- .memory(store) ⇒ Object
- .missing_document(path) ⇒ Object
- .numbered_window(content, offset, limit) ⇒ Object
- .read_file(workspace) ⇒ Object
- .read_memory(memory) ⇒ Object
- .replace_atomically(workspace, args) ⇒ Object
- .replace_legacy(workspace, args) ⇒ Object
- .replacement(content, args) ⇒ Object
- .same_content_bytes?(left, right) ⇒ Boolean
-
.tolerate(args) ⇒ Object
Absorb the drift real models produce for edit_file only.
-
.update_memory(memory) ⇒ Object
Whole-document replace on purpose: the model rewrites memory as one coherent text instead of appending fragments that drift.
- .with_document(workspace, args) {|content| ... } ⇒ Object
- .write_file(workspace) ⇒ Object
Class Method Details
.atomic_workspace?(workspace) ⇒ Boolean
Atomic writes are an explicit backend claim. A false or absent claim preserves the legacy four-method port; a true but incomplete claim fails before the model can rely on safety the backend does not implement.
43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 |
# File 'lib/mistri/tools.rb', line 43 def atomic_workspace?(workspace) return false unless workspace.respond_to?(:atomic_writes?) supported = workspace.atomic_writes? unless [true, false].include?(supported) raise ConfigurationError, "workspace atomic_writes? must return true or false" end return false unless supported missing = ATOMIC_WORKSPACE_METHODS.reject { |method| workspace.respond_to?(method) } unless missing.empty? raise ConfigurationError, "atomic workspace is missing #{missing.map(&:inspect).join(" and ")}" end true end |
.edit_file(workspace) ⇒ Object
The model-facing shape is flat old_string, new_string, replace_all on purpose: it is the shape frontier models are trained on, and nested edit arrays measurably degrade their calls. Failures come back in band with the closest region and its exact difference, so the model's retry is one shot.
15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
# File 'lib/mistri/tools/edit_file.rb', line 15 def edit_file(workspace) atomic = atomic_workspace?(workspace) Tool.define("edit_file", "Replace an exact snippet of a document. Copy old_string verbatim from " \ "read_file output including whitespace, without line-number prefixes. " \ "It must match exactly one place; add surrounding lines to make it " \ "unique, or set replace_all to change every occurrence.", eager_input_streaming: true, argument_normalizer: Tools.method(:tolerate), schema: lambda { string :path, "Document path", required: true string :old_string, "Exact text to replace (whitespace matters)", required: true string :new_string, "Replacement text", required: true boolean :replace_all, "Replace every occurrence instead of exactly one" }) do |args| result = if atomic replace_atomically(workspace, args) else replace_legacy(workspace, args) end next result if result.is_a?(ToolResult) "Replaced #{result.count} occurrence(s) in #{args["path"]}" rescue EditError, WorkspaceConflictError => e ToolResult.new(content: "edit_file failed: #{e.}", error: true) end end |
.files(workspace) ⇒ Object
17 18 19 20 |
# File 'lib/mistri/tools.rb', line 17 def files(workspace) [read_file(workspace), write_file(workspace), edit_file(workspace), find_in_file(workspace), list_files(workspace)] end |
.find_in_file(workspace) ⇒ Object
7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
# File 'lib/mistri/tools/find_in_file.rb', line 7 def find_in_file(workspace) Tool.define("find_in_file", "Find text in a document. Returns line-numbered matches with context, " \ "so you can locate a region without reading the whole document.", schema: lambda { string :path, "Document path", required: true string :query, "Text to find (plain substring)", required: true integer :context, "Context lines around each match" }) do |args| with_document(workspace, args) do |content| Tools.find_matches(content, args["query"], (args["context"] || 2).to_i) end end end |
.find_matches(content, query, context) ⇒ Object
22 23 24 25 26 27 28 29 30 31 32 33 34 |
# File 'lib/mistri/tools/find_in_file.rb', line 22 def find_matches(content, query, context) lines = content.lines hits = lines.each_index.select { |i| lines[i].include?(query) } return "No matches for #{query.inspect}." if hits.empty? blocks = hits.first(20).map do |hit| from = [hit - context, 0].max to = [hit + context, lines.length - 1].min (from..to).map { |n| "#{n + 1}: #{lines[n]}" }.join end notice = hits.length > 20 ? "\n[#{hits.length - 20} more matches not shown]" : "" "#{blocks.join("---\n")}#{notice}" end |
.list_files(workspace) ⇒ Object
7 8 9 10 11 12 13 14 |
# File 'lib/mistri/tools/list_files.rb', line 7 def list_files(workspace) Tool.define("list_files", "List document paths in the workspace, optionally under a prefix.", schema: -> { string :prefix, "Only paths starting with this" }) do |args| paths = workspace.list(args["prefix"]) paths.empty? ? "No documents found." : paths.join("\n") end end |
.memory(store) ⇒ Object
22 23 24 |
# File 'lib/mistri/tools.rb', line 22 def memory(store) [read_memory(store), update_memory(store)] end |
.missing_document(path) ⇒ Object
33 34 35 36 37 38 |
# File 'lib/mistri/tools.rb', line 33 def missing_document(path) ToolResult.new( content: "No document at #{path.inspect}. Use list_files to see paths.", error: true ) end |
.numbered_window(content, offset, limit) ⇒ Object
24 25 26 27 28 29 30 31 32 33 34 35 36 |
# File 'lib/mistri/tools/read_file.rb', line 24 def numbered_window(content, offset, limit) lines = content.lines from = [(offset || 1).to_i, 1].max to = limit ? [from + limit.to_i - 1, lines.length].min : lines.length numbered = (from..to).map { |n| "#{n}: #{lines[n - 1]}" }.join windowed = to < lines.length || from > 1 suffix = windowed ? "\n[showing lines #{from}-#{to} of #{lines.length}]" : "" return "#{numbered}#{suffix}" if numbered.length <= MAX_READ_CHARS cut = numbered[0, MAX_READ_CHARS] cut = cut[0..(cut.rindex("\n") || -1)] "#{cut}\n[truncated at #{MAX_READ_CHARS} chars; use offset/limit to read more]" end |
.read_file(workspace) ⇒ Object
9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
# File 'lib/mistri/tools/read_file.rb', line 9 def read_file(workspace) Tool.define("read_file", "Read a document with line numbers. Use offset and limit for a window " \ "into a long document.", schema: lambda { string :path, "Document path", required: true integer :offset, "First line to read (1-based)" integer :limit, "How many lines to read" }) do |args| with_document(workspace, args) do |content| Tools.numbered_window(content, args["offset"], args["limit"]) end end end |
.read_memory(memory) ⇒ Object
7 8 9 10 11 12 13 14 |
# File 'lib/mistri/tools/read_memory.rb', line 7 def read_memory(memory) Tool.define("read_memory", "Read the durable memory: knowledge kept across sessions. Check it " \ "before starting work that earlier sessions may have learned about.") do |_args| content = memory.read content.empty? ? "Memory is empty." : content end end |
.replace_atomically(workspace, args) ⇒ Object
51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 |
# File 'lib/mistri/tools/edit_file.rb', line 51 def replace_atomically(workspace, args) MAX_ATOMIC_EDIT_ATTEMPTS.times do |attempt| snapshot = workspace.snapshot(args["path"]) return missing_document(args["path"]) unless snapshot unless snapshot.is_a?(Workspace::Snapshot) raise TypeError, "workspace snapshot must be a Mistri::Workspace::Snapshot" end result = replacement(snapshot.content, args) begin committed = workspace.compare_and_write( args["path"], result.content, expected_revision: snapshot.revision ) unless committed.is_a?(Workspace::Snapshot) raise TypeError, "workspace compare_and_write must return a Mistri::Workspace::Snapshot" end unless same_content_bytes?(committed.content, result.content) return ToolResult.new( content: "The write to #{args["path"].inspect} committed, but storage " \ "transformed the resulting document. Use read_file before continuing.", error: true ) end return result rescue WorkspaceConflictError raise if attempt == MAX_ATOMIC_EDIT_ATTEMPTS - 1 end end end |
.replace_legacy(workspace, args) ⇒ Object
43 44 45 46 47 48 49 |
# File 'lib/mistri/tools/edit_file.rb', line 43 def replace_legacy(workspace, args) with_document(workspace, args) do |content| result = replacement(content, args) workspace.write(args["path"], result.content) result end end |
.replacement(content, args) ⇒ Object
82 83 84 85 |
# File 'lib/mistri/tools/edit_file.rb', line 82 def replacement(content, args) Edit.replace(content, args["old_string"], args["new_string"], replace_all: args["replace_all"] == true) end |
.same_content_bytes?(left, right) ⇒ Boolean
87 88 89 |
# File 'lib/mistri/tools/edit_file.rb', line 87 def same_content_bytes?(left, right) left == right || (left.bytesize == right.bytesize && left.b == right.b) end |
.tolerate(args) ⇒ Object
Absorb the drift real models produce for edit_file only. Ambiguous aliases fail instead of letting hash insertion order choose an edit.
63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 |
# File 'lib/mistri/tools.rb', line 63 def tolerate(args) normalized = args.each_with_object({}) do |(key, value), copy| canonical = ALIASES.fetch(key.to_s, key.to_s) if copy.key?(canonical) raise ArgumentError, "multiple arguments map to #{canonical.inspect}" end copy[canonical] = value end case normalized["replace_all"] when "true", "1", 1 then normalized["replace_all"] = true when "false", "0", 0, nil then normalized["replace_all"] = false end normalized end |
.update_memory(memory) ⇒ Object
Whole-document replace on purpose: the model rewrites memory as one coherent text instead of appending fragments that drift.
9 10 11 12 13 14 15 16 17 18 19 20 |
# File 'lib/mistri/tools/update_memory.rb', line 9 def update_memory(memory) Tool.define("update_memory", "Replace the durable memory with an updated version. Pass the FULL " \ "text: what you were given plus what you learned, rewritten to stay " \ "short and current.", schema: lambda { string :content, "The complete new memory text", required: true }) do |args| memory.replace(args["content"]) "Memory updated (#{args["content"].to_s.length} chars)." end end |
.with_document(workspace, args) {|content| ... } ⇒ Object
26 27 28 29 30 31 |
# File 'lib/mistri/tools.rb', line 26 def with_document(workspace, args) content = workspace.read(args["path"]) return missing_document(args["path"]) if content.nil? yield content end |
.write_file(workspace) ⇒ Object
7 8 9 10 11 12 13 14 15 16 17 18 |
# File 'lib/mistri/tools/write_file.rb', line 7 def write_file(workspace) Tool.define("write_file", "Create or fully overwrite a document with the given content.", eager_input_streaming: true, schema: lambda { string :path, "Document path", required: true string :content, "The full document content", required: true }) do |args| workspace.write(args["path"], args["content"]) "Wrote #{args["path"]} (#{args["content"].to_s.length} chars)" end end |