use std::path::Path;
use std::sync::Arc;
use async_trait::async_trait;
use locode_host::{FsError, Host};
use locode_tools::{Tool, ToolCtx, ToolError, ToolKind, ToolOutput};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use super::state::ClaudeSessionState;
const DEFAULT_OFFSET: u64 = 1;
const MAX_TOKENS: usize = 25_000;
const FILE_UNCHANGED_STUB: &str = "File unchanged since last read. The content from the earlier Read tool_result in this conversation is still current — refer to that instead of re-reading.";
pub(crate) struct ClaudeRead {
host: Arc<Host>,
state: Arc<ClaudeSessionState>,
}
impl ClaudeRead {
pub(crate) fn new(host: Arc<Host>, state: Arc<ClaudeSessionState>) -> Self {
Self { host, state }
}
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct ReadArgs {
#[schemars(description = "The absolute path to the file to read")]
file_path: String,
#[schemars(
description = "The line number to start reading from. Only provide if the file is too large to read at once"
)]
#[serde(default)]
offset: Option<u64>,
#[schemars(
description = "The number of lines to read. Only provide if the file is too large to read at once."
)]
#[serde(default)]
limit: Option<u64>,
#[schemars(
description = "Page range for PDF files (e.g., \"1-5\", \"3\", \"10-20\"). Only applicable to PDF files. Maximum 20 pages per request."
)]
#[serde(default)]
#[allow(dead_code)] pages: Option<String>,
}
#[derive(Debug, Serialize)]
pub(crate) struct ReadOutput {
path: String,
lines: usize,
truncated: bool,
#[serde(skip)]
body: String,
}
impl ToolOutput for ReadOutput {
fn to_prompt_text(&self) -> String {
self.body.clone()
}
}
fn estimate_tokens(s: &str) -> usize {
s.len() / 4
}
fn read_error_text(display_path: &str, err: &FsError) -> String {
match err {
FsError::Io { source, .. } => match source.kind() {
std::io::ErrorKind::NotFound => format!("Error: {display_path} does not exist."),
std::io::ErrorKind::IsADirectory => {
format!("Error: {display_path} is a directory, not a file.")
}
std::io::ErrorKind::PermissionDenied => format!("Permission denied: {display_path}"),
_ => format!("Failed to read file: {display_path}, {err}"),
},
FsError::Path(_) => err.to_string(),
}
}
#[async_trait]
impl Tool for ClaudeRead {
type Args = ReadArgs;
type Output = ReadOutput;
fn kind(&self) -> ToolKind {
ToolKind::Read
}
#[allow(clippy::unnecessary_literal_bound)] fn description(&self) -> &str {
include_str!("descriptions/read.md")
}
async fn run(&self, ctx: &ToolCtx, args: ReadArgs) -> Result<Self::Output, ToolError> {
let path = Path::new(&args.file_path);
let resolved = self
.host
.resolve_in_jail(&ctx.cwd, path)
.await
.map_err(|e| ToolError::Respond(e.to_string()))?;
let read = self
.host
.read_file(&ctx.cwd, path)
.await
.map_err(|e| ToolError::Respond(read_error_text(&args.file_path, &e)))?;
let contents = read.contents;
let modified = read.stat.modified;
let effective_offset = args.offset.unwrap_or(DEFAULT_OFFSET);
let limit = args.limit;
if self
.state
.is_unchanged_read(&resolved, modified, Some(effective_offset), limit)
{
return Ok(ReadOutput {
path: resolved.display().to_string(),
lines: contents.lines().count(),
truncated: false,
body: FILE_UNCHANGED_STUB.to_string(),
});
}
let lines: Vec<&str> = contents.lines().collect();
let total_lines = lines.len();
let line_offset = if effective_offset == 0 {
0
} else {
usize::try_from(effective_offset - 1).unwrap_or(usize::MAX)
};
let take = limit.map_or(usize::MAX, |l| usize::try_from(l).unwrap_or(usize::MAX));
let windowed: Vec<(u64, &str)> = lines
.iter()
.enumerate()
.skip(line_offset)
.take(take)
.map(|(i, &line)| {
let n = effective_offset + (i as u64 - line_offset as u64);
(n, line)
})
.collect();
let raw: String = windowed
.iter()
.map(|(_, l)| *l)
.collect::<Vec<_>>()
.join("\n");
let token_count = estimate_tokens(&raw);
if token_count > MAX_TOKENS {
return Err(ToolError::Respond(format!(
"File content ({token_count} tokens) exceeds maximum allowed tokens ({MAX_TOKENS}). \
Use offset and limit parameters to read specific portions of the file, or search \
for specific content instead of reading the whole file."
)));
}
let body = if windowed.is_empty() {
if total_lines == 0 {
"<system-reminder>Warning: the file exists but the contents are empty.</system-reminder>"
.to_string()
} else {
format!(
"<system-reminder>Warning: the file exists but is shorter than the provided \
offset ({effective_offset}). The file has {total_lines} lines.</system-reminder>"
)
}
} else {
windowed
.iter()
.map(|(n, l)| format!("{n}\t{l}"))
.collect::<Vec<_>>()
.join("\n")
};
self.state
.record_read(resolved.clone(), modified, Some(effective_offset), limit);
let truncated = line_offset > 0 || line_offset + windowed.len() < total_lines;
Ok(ReadOutput {
path: resolved.display().to_string(),
lines: total_lines,
truncated,
body,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn description_is_the_pinned_read_md() {
let desc = include_str!("descriptions/read.md");
assert_eq!(desc.len(), 1680, "read.md byte length changed");
assert!(desc.starts_with("Reads a file from the local filesystem."));
assert!(
desc.trim_end()
.ends_with("you will receive a system reminder warning in place of file contents.")
);
assert!(desc.contains("This tool can read PDF files (.pdf)."));
}
#[test]
fn estimate_tokens_is_byte_quarter() {
assert_eq!(estimate_tokens(&"x".repeat(400)), 100);
}
}