Current section
Files
Jump to
Current section
Files
lib/cmdc/tool/read_file.ex
defmodule CMDC.Tool.ReadFile do
@moduledoc """
读取文件内容,支持 offset/limit 分页。
- 有 Sandbox 时走 `sandbox.read_file/2`
- 无 Sandbox 时走 `CMDC.Sandbox.Local`
- 输出超过行数或字节阈值时自动截断并附带续读提示
"""
@behaviour CMDC.Tool
alias CMDC.Sandbox.Local, as: SandboxLocal
@max_lines 2_000
@max_bytes 50 * 1024
@impl true
def name, do: "read_file"
@impl true
def description, do: "Read the contents of a file at the given path."
@impl true
def meta(%{"path" => path}), do: "Read #{path}"
def meta(_), do: "Read file"
@impl true
def parameters do
%{
"type" => "object",
"properties" => %{
"path" => %{
"type" => "string",
"description" => "Path to the file to read (relative to working_dir or absolute)"
},
"offset" => %{
"type" => "integer",
"description" => "Line number to start reading from (1-indexed, default: 1)"
},
"limit" => %{
"type" => "integer",
"description" => "Maximum number of lines to read"
}
},
"required" => ["path"]
}
end
@impl true
def execute(%{"path" => path} = args, %CMDC.Context{} = ctx) do
offset = Map.get(args, "offset", 1)
limit = Map.get(args, "limit")
sandbox_opts = [
working_dir: ctx.working_dir,
offset: offset,
limit: limit
]
result =
if ctx.sandbox do
ctx.sandbox.read_file(path, sandbox_opts)
else
SandboxLocal.read_file(path, sandbox_opts)
end
case result do
{:ok, content} -> format_output(content, offset)
{:error, reason} -> {:error, reason}
end
end
def execute(_args, _ctx), do: {:error, "Missing required parameter: path"}
# ==========================================================================
# 私有 — 输出格式化与截断
# ==========================================================================
defp format_output(content, start_line) do
content = String.replace(content, "\r\n", "\n")
tagged =
content
|> String.split("\n")
|> Enum.with_index(start_line)
|> Enum.map_join("\n", fn {line, num} -> "#{num}|#{line}" end)
truncate_output(tagged, start_line)
end
defp truncate_output(content, start_line) do
lines = String.split(content, "\n")
total = length(lines)
cond do
total > @max_lines ->
text = lines |> Enum.take(@max_lines) |> Enum.join("\n")
end_line = start_line + @max_lines - 1
{:ok,
text <>
"\n\n[Showing lines #{start_line}-#{end_line}. " <>
"Use offset=#{end_line + 1} to continue.]"}
byte_size(content) > @max_bytes ->
truncated = truncate_at_bytes(content, @max_bytes)
shown = length(String.split(truncated, "\n"))
end_line = start_line + shown - 1
{:ok,
truncated <>
"\n\n[Output truncated at #{div(@max_bytes, 1024)}KB. " <>
"Use offset=#{end_line + 1} to continue.]"}
true ->
{:ok, content}
end
end
defp truncate_at_bytes(content, max_bytes) do
if byte_size(content) <= max_bytes do
content
else
truncated = binary_part(content, 0, max_bytes)
case :binary.match(truncated, "\n",
scope: {byte_size(truncated) - 1, -(byte_size(truncated) - 1)}
) do
{pos, _} -> binary_part(truncated, 0, pos + 1)
:nomatch -> truncated
end
end
end
end