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};
const MAX_LINES: usize = 1_000;
const MAX_TOKENS: usize = 25_000;
struct GrokIntegerSchema;
impl JsonSchema for GrokIntegerSchema {
fn schema_name() -> std::borrow::Cow<'static, str> {
"grok_integer_schema".into()
}
fn inline_schema() -> bool {
true }
fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({ "type": "integer" })
}
}
pub(crate) struct GrokReadFile {
host: Arc<Host>,
}
impl GrokReadFile {
pub(crate) fn new(host: Arc<Host>) -> Self {
Self { host }
}
}
#[derive(Debug, Deserialize, JsonSchema)]
pub(crate) struct ReadFileArgs {
#[serde(rename = "target_file")]
#[schemars(
description = "The path of the file to read. You can use either a relative path in the workspace or an absolute path. If an absolute path is provided, it will be preserved as is."
)]
path: String,
#[serde(default)]
#[schemars(
with = "GrokIntegerSchema",
description = "The line number to start reading from. Only provide if the file is too large to read at once."
)]
offset: Option<i64>,
#[serde(default)]
#[schemars(
with = "GrokIntegerSchema",
description = "The number of lines to read. Only provide if the file is too large to read at once."
)]
limit: Option<usize>,
#[serde(default)]
#[schemars(
description = "Page range for PDF files (e.g. '1-5', '3', '10-'). Required for PDFs with more than 10 pages. Max 20 pages per call. Ignored for non-PDF files."
)]
#[allow(dead_code)] pages: Option<String>,
#[serde(default)]
#[schemars(
description = "Output format for PDF files. 'image' (default) renders pages as images. 'text' extracts text content. Ignored for non-PDF files."
)]
#[allow(dead_code)] format: Option<String>,
}
#[derive(Debug, Serialize)]
pub(crate) struct ReadFileOutput {
path: String,
lines: usize,
truncated: bool,
#[serde(skip)]
body: String,
}
impl ToolOutput for ReadFileOutput {
fn to_prompt_text(&self) -> String {
self.body.clone()
}
}
fn resolve_read_start_line(file_content: &str, offset: Option<i64>) -> usize {
let offset_raw = offset.unwrap_or(1);
if offset_raw == 0 {
return 1;
}
if offset_raw > 0 {
return usize::try_from(offset_raw).unwrap_or(usize::MAX);
}
let mut total_fields = file_content.split('\n').count();
if !file_content.is_empty() && !file_content.ends_with('\n') {
total_fields += 1;
}
let computed = i64::try_from(total_fields).unwrap_or(i64::MAX) + offset_raw + 1;
usize::try_from(computed.max(1)).unwrap_or(1)
}
struct Extracted {
content: String,
raw_output: String,
}
fn extract_file_content_lines(
file_content: &str,
offset: Option<i64>,
limit: Option<usize>,
total_lines: usize,
) -> Extracted {
use std::fmt::Write as _;
fn strip(s: &str) -> &str {
let Some(s) = s.strip_suffix('\n') else {
return s;
};
let Some(line) = s.strip_suffix('\r') else {
return s;
};
line
}
let mut output = String::new();
let (mut start, mut end) = (0, 0);
let mut first_line: Option<usize> = None;
let split_count = file_content.split_inclusive('\n').count();
let has_trailing_empty = !file_content.is_empty() && file_content.ends_with('\n');
let skip = resolve_read_start_line(file_content, offset).saturating_sub(1);
let take = limit.unwrap_or(usize::MAX);
if file_content.is_empty() && total_lines > 0 && skip == 0 && take > 0 {
let _ = write!(&mut output, "1→");
first_line = Some(1);
}
for (i, (pos, line_len, line)) in file_content
.split_inclusive('\n')
.scan(0, |pos, line| {
let out = *pos;
let line_len = line.len();
*pos += line_len;
Some((out, line_len, strip(line)))
})
.enumerate()
.skip(skip)
.take(take)
{
let is_first_visible = first_line.is_none();
if is_first_visible {
start = pos;
first_line = Some(i + 1);
} else {
output.push('\n');
}
end = pos + line_len;
let line_num = i + 1;
if is_first_visible || line_num.is_multiple_of(10) {
let _ = write!(&mut output, "{line_num}→{line}");
} else {
output.push_str(line);
}
}
if has_trailing_empty {
let trailing_line_idx = split_count;
if trailing_line_idx >= skip && trailing_line_idx < skip.saturating_add(take) {
let line_num = trailing_line_idx + 1;
let is_first_visible = first_line.is_none();
if is_first_visible {
first_line = Some(line_num);
} else {
output.push('\n');
}
if is_first_visible || line_num.is_multiple_of(10) {
let _ = write!(&mut output, "{line_num}→");
}
}
}
let mut raw_output = if first_line.is_none() || file_content.is_empty() {
String::new()
} else {
file_content[start..end].to_owned()
};
if raw_output.ends_with("\r\n") {
raw_output.truncate(raw_output.len().saturating_sub(2));
raw_output.push('\n');
}
Extracted {
content: output,
raw_output,
}
}
fn estimate_tokens(s: &str) -> usize {
s.len() / 4
}
fn too_large_message(
token_count: usize,
offset: Option<i64>,
limit: Option<usize>,
single_content_line: bool,
) -> String {
let single_line_hint = if single_content_line {
"\nNote: the requested read is a single very long line, so line-based offset/limit cannot narrow it further. Use the 'run_terminal_cmd' tool to extract the parts you need (e.g. `jq`, `python3`, or `cut -c`)."
} else {
""
};
if offset.is_some() || limit.is_some() {
let off = offset.map_or_else(|| "1".to_string(), |v| v.to_string());
let lim = limit.map_or_else(|| "to end".to_string(), |v| v.to_string());
format!(
"The requested line range (offset={off}, limit={lim}) contains {token_count} tokens, \
which exceeds the maximum allowed tokens ({MAX_TOKENS} tokens).\n\
Try a smaller `limit`, a different starting `offset`, \
or use the 'grep' tool to search for specific content.{single_line_hint}"
)
} else {
format!(
"File content ({token_count} tokens) exceeds maximum allowed tokens ({MAX_TOKENS} tokens).\n\
Please use offset and limit parameters to read a shorter range, \
or use the 'grep' to search for specific content.{single_line_hint}"
)
}
}
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 GrokReadFile {
type Args = ReadFileArgs;
type Output = ReadFileOutput;
fn kind(&self) -> ToolKind {
ToolKind::Read
}
#[allow(clippy::unnecessary_literal_bound)] fn description(&self) -> &str {
"Read a file.\n\nUsage:\n- The target_file parameter can be a relative path in the workspace or an absolute path\n- By default, it reads up to 1000 lines starting from the beginning of the file\n- Results are returned with line numbers starting at 1. The format is: LINE_NUMBER→LINE_CONTENT"
}
async fn run(&self, ctx: &ToolCtx, args: ReadFileArgs) -> Result<Self::Output, ToolError> {
let path = Path::new(&args.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.path, &e)))?;
let file_content = read.contents;
let total_lines = file_content.matches('\n').count() + 1;
let effective_limit = Some(args.limit.unwrap_or(usize::MAX).min(MAX_LINES));
let extracted =
extract_file_content_lines(&file_content, args.offset, effective_limit, total_lines);
let token_count = estimate_tokens(&extracted.content);
if token_count > MAX_TOKENS {
let single_content_line = extracted.raw_output.lines().count() <= 1;
return Err(ToolError::Respond(too_large_message(
token_count,
args.offset,
args.limit,
single_content_line,
)));
}
let start = resolve_read_start_line(&file_content, args.offset);
let limit = args.limit.unwrap_or(usize::MAX).min(MAX_LINES);
let truncated = start > 1 || (start - 1).saturating_add(limit) < total_lines;
Ok(ReadFileOutput {
path: resolved.display().to_string(),
lines: total_lines,
truncated,
body: extracted.content,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sparse_numbering_matches_grok_fixture() {
let file = (1..=12).fold(String::new(), |mut acc, i| {
use std::fmt::Write as _;
let _ = writeln!(acc, "L{i}");
acc
});
let extracted = extract_file_content_lines(&file, None, None, 13);
assert_eq!(
extracted.content,
"1→L1\nL2\nL3\nL4\nL5\nL6\nL7\nL8\nL9\n10→L10\nL11\nL12\n"
);
}
#[test]
fn offset_window_anchors_first_visible() {
let extracted = extract_file_content_lines("a\nb\nc\nd\ne\n", Some(3), None, 6);
assert_eq!(extracted.content, "3→c\nd\ne\n");
}
#[test]
fn negative_offset_tail_semantics() {
assert_eq!(resolve_read_start_line("a\nb\nc\n", Some(-3)), 2);
assert_eq!(resolve_read_start_line("a\nb\nc\n", Some(-999)), 1);
assert_eq!(resolve_read_start_line("", Some(0)), 1);
assert_eq!(resolve_read_start_line("a\nb\nc", Some(-1)), 4);
let five = "line1\nline2\nline3\nline4\nline5\n";
let extracted = extract_file_content_lines(five, Some(-2), Some(2), 6);
assert_eq!(extracted.content, "5→line5\n");
}
#[test]
fn phantom_only_window_is_empty() {
let extracted = extract_file_content_lines("a\nb\nc", Some(-1), None, 3);
assert_eq!(extracted.content, "");
}
#[test]
fn empty_file_renders_single_anchor() {
let extracted = extract_file_content_lines("", None, Some(1_000), 1);
assert_eq!(extracted.content, "1→");
}
#[test]
fn schema_has_all_five_fields_with_bare_integers() {
let schema = serde_json::to_value(schemars::schema_for!(ReadFileArgs)).unwrap();
let props = schema["properties"].as_object().unwrap();
for key in ["target_file", "offset", "limit", "pages", "format"] {
assert!(props.contains_key(key), "missing schema field {key}");
}
for key in ["offset", "limit"] {
let field = props[key].as_object().unwrap();
assert_eq!(field.get("type").and_then(|t| t.as_str()), Some("integer"));
assert!(!field.contains_key("format"), "{key} carries format");
assert!(!field.contains_key("minimum"), "{key} carries minimum");
}
}
#[test]
fn offset_rejects_string_and_float_forms() {
for bad in [
serde_json::json!({"target_file": "f", "offset": "42"}),
serde_json::json!({"target_file": "f", "offset": 100.0}),
] {
assert!(serde_json::from_value::<ReadFileArgs>(bad).is_err());
}
}
#[test]
fn too_large_messages_match_grok() {
let range = too_large_message(30_000, Some(5), None, false);
assert_eq!(
range,
"The requested line range (offset=5, limit=to end) contains 30000 tokens, which exceeds the maximum allowed tokens (25000 tokens).\nTry a smaller `limit`, a different starting `offset`, or use the 'grep' tool to search for specific content."
);
let plain = too_large_message(30_000, None, None, true);
assert_eq!(
plain,
"File content (30000 tokens) exceeds maximum allowed tokens (25000 tokens).\nPlease use offset and limit parameters to read a shorter range, or use the 'grep' to search for specific content.\nNote: the requested read is a single very long line, so line-based offset/limit cannot narrow it further. Use the 'run_terminal_cmd' tool to extract the parts you need (e.g. `jq`, `python3`, or `cut -c`)."
);
}
}