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

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.

Returns:

  • (Boolean)


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.message}", 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

Returns:

  • (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

Yields:

  • (content)


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