use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use agent_client_protocol_schema::{
Content, ContentBlock, ImageContent, TextContent, ToolCallContent, ToolCallLocation,
ToolCallUpdateFields, ToolKind,
};
use base64::Engine;
use defect_agent::error::BoxError;
use defect_agent::fs::{FsBackend, FsError};
use defect_agent::tool::{
SafetyClass, Tool, ToolCallDescription, ToolContext, ToolError, ToolEvent, ToolSchema,
ToolStream,
};
use defect_config::FsToolConfig;
use futures::future::BoxFuture;
use futures::stream;
use serde::{Deserialize, Serialize};
use serde_json::json;
const DEFAULT_LIMIT: u32 = 2000;
const MAX_LIMIT: u32 = 5000;
pub struct ReadFileTool {
schema: ToolSchema,
default_limit: u32,
max_limit: u32,
}
impl ReadFileTool {
pub fn new() -> Self {
Self::from_config(&FsToolConfig {
read_default_limit: DEFAULT_LIMIT,
read_max_limit: MAX_LIMIT,
})
}
pub fn from_config(config: &FsToolConfig) -> Self {
let default_limit = config.read_default_limit.max(1);
let max_limit = config.read_max_limit.max(default_limit);
Self {
schema: ToolSchema {
name: "read_file".to_string(),
description: "Read a file from the workspace. \
For UTF-8 text files: optionally read a window starting at `offset` (1-based line) for `limit` lines; \
returns the content with 1-based line numbers prepended. \
For image files (.png/.jpg/.jpeg/.gif/.webp): returns the image itself as visual content (offset/limit ignored). \
Refuses other binary files and files larger than 10 MiB."
.to_string(),
input_schema: json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Absolute path or path relative to the session cwd. \
Must resolve inside the workspace root."
},
"offset": {
"type": "integer",
"minimum": 1,
"description": "Optional 1-based start line (inclusive). Defaults to 1."
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": max_limit,
"description": format!(
"Optional max number of lines to read. Defaults to {default_limit}."
)
}
},
"required": ["path"]
}),
},
default_limit,
max_limit,
}
}
}
impl Default for ReadFileTool {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Deserialize)]
struct ReadArgs {
path: String,
#[serde(default)]
offset: Option<u32>,
#[serde(default)]
limit: Option<u32>,
}
#[derive(Debug, Serialize)]
struct ReadFileOutput {
bytes: u64,
lines_returned: u32,
start_line: u32,
truncated: bool,
}
impl Tool for ReadFileTool {
fn schema(&self) -> &ToolSchema {
&self.schema
}
fn safety_hint(&self, _args: &serde_json::Value) -> SafetyClass {
SafetyClass::ReadOnly
}
fn describe<'a>(
&'a self,
args: &'a serde_json::Value,
_ctx: ToolContext<'a>,
) -> BoxFuture<'a, ToolCallDescription> {
Box::pin(async move {
let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
let offset = args
.get("offset")
.and_then(|v| v.as_u64())
.map(|n| n as u32);
let title = if path.is_empty() {
"Read".to_string()
} else {
format!("Read {path}")
};
let mut fields = ToolCallUpdateFields::default();
fields.title = Some(title);
fields.kind = Some(ToolKind::Read);
if !path.is_empty() {
fields.locations = Some(vec![
ToolCallLocation::new(PathBuf::from(path)).line(offset),
]);
}
ToolCallDescription { fields }
})
}
fn execute(&self, args: serde_json::Value, ctx: ToolContext<'_>) -> ToolStream {
let cancel = ctx.cancel.clone();
let fs = ctx.fs.clone();
let default_limit = self.default_limit;
let max_limit = self.max_limit;
let fut = async move { run_read(args, cancel, fs, default_limit, max_limit).await };
let s: Pin<Box<dyn futures::Stream<Item = ToolEvent> + Send>> = Box::pin(stream::once(fut));
s
}
}
async fn run_read(
args: serde_json::Value,
cancel: tokio_util::sync::CancellationToken,
fs: Arc<dyn FsBackend>,
default_limit: u32,
max_limit: u32,
) -> ToolEvent {
let parsed: ReadArgs = match serde_json::from_value(args) {
Ok(v) => v,
Err(err) => return ToolEvent::Failed(ToolError::InvalidArgs(BoxError::new(err))),
};
if let Some(mime) = image_mime(&parsed.path) {
return run_read_image(parsed.path, mime, cancel, fs).await;
}
let limit = parsed.limit.unwrap_or(default_limit).min(max_limit).max(1);
let offset = parsed.offset.unwrap_or(1).max(1);
let path = PathBuf::from(&parsed.path);
let read_fut = fs.read_text(path, Some(offset), Some(limit));
let text = tokio::select! {
biased;
() = cancel.cancelled() => return ToolEvent::Failed(ToolError::Canceled),
r = read_fut => match r {
Ok(t) => t,
Err(e) => return ToolEvent::Failed(map_fs_err(e)),
},
};
let lines_returned = text.split_inclusive('\n').count() as u32;
let truncated = lines_returned >= limit;
let bytes = text.len() as u64;
let formatted = format_with_line_numbers(&text, offset);
let raw_output = serde_json::to_value(ReadFileOutput {
bytes,
lines_returned,
start_line: offset,
truncated,
})
.unwrap_or(serde_json::Value::Null);
let mut fields = ToolCallUpdateFields::default();
fields.content = Some(vec![ToolCallContent::Content(Content::new(
ContentBlock::Text(TextContent::new(formatted)),
))]);
fields.raw_output = Some(raw_output);
ToolEvent::Completed(fields)
}
#[derive(Debug, Serialize)]
struct ReadImageOutput {
bytes: u64,
mime: String,
}
async fn run_read_image(
path: String,
mime: &'static str,
cancel: tokio_util::sync::CancellationToken,
fs: Arc<dyn FsBackend>,
) -> ToolEvent {
let read_fut = fs.read_bytes(PathBuf::from(&path));
let bytes = tokio::select! {
biased;
() = cancel.cancelled() => return ToolEvent::Failed(ToolError::Canceled),
r = read_fut => match r {
Ok(b) => b,
Err(e) => return ToolEvent::Failed(map_fs_err(e)),
},
};
let byte_len = bytes.len() as u64;
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
let raw_output = serde_json::to_value(ReadImageOutput {
bytes: byte_len,
mime: mime.to_string(),
})
.unwrap_or(serde_json::Value::Null);
let mut fields = ToolCallUpdateFields::default();
fields.content = Some(vec![ToolCallContent::Content(Content::new(
ContentBlock::Image(ImageContent::new(encoded, mime.to_string())),
))]);
fields.raw_output = Some(raw_output);
ToolEvent::Completed(fields)
}
fn image_mime(path: &str) -> Option<&'static str> {
let ext = std::path::Path::new(path)
.extension()
.and_then(|e| e.to_str())?
.to_ascii_lowercase();
match ext.as_str() {
"png" => Some("image/png"),
"jpg" | "jpeg" => Some("image/jpeg"),
"gif" => Some("image/gif"),
"webp" => Some("image/webp"),
_ => None,
}
}
fn map_fs_err(e: FsError) -> ToolError {
ToolError::Execution(BoxError::new(e))
}
fn format_with_line_numbers(text: &str, offset: u32) -> String {
let mut out = String::new();
let mut idx = offset;
for line in text.split_inclusive('\n') {
let display = line.strip_suffix('\n').unwrap_or(line);
out.push_str(&format!("{idx:>4}| {display}\n"));
idx = idx.saturating_add(1);
}
out
}