diff --git a/crates/xgen-adapter-filesystem/src/lib.rs b/crates/xgen-adapter-filesystem/src/lib.rs
index d67f2be..81d4946 100644
--- a/crates/xgen-adapter-filesystem/src/lib.rs
+++ b/crates/xgen-adapter-filesystem/src/lib.rs
@@ -22,8 +22,9 @@ pub use apply_patch::{
};
pub use query::{
FilesystemQueryAdapter, FilesystemQueryVerifier, MAX_DIRECTORY_SCAN_ENTRIES,
- MAX_LIST_DIRECTORY_ENTRIES, MAX_QUERY_OUTPUT_CANONICAL_BYTES, MAX_SEARCH_FILE_BYTES,
- MAX_SEARCH_MATCHES, MAX_SEARCH_PREVIEW_BYTES, MAX_SEARCH_QUERY_BYTES,
+ MAX_LIST_DIRECTORY_ENTRIES, MAX_QUERY_OUTPUT_CANONICAL_BYTES,
+ MAX_READ_TEXT_LINES_CONTENT_BYTES, MAX_READ_TEXT_LINES_COUNT, MAX_READ_TEXT_LINES_FILE_BYTES,
+ MAX_SEARCH_FILE_BYTES, MAX_SEARCH_MATCHES, MAX_SEARCH_PREVIEW_BYTES, MAX_SEARCH_QUERY_BYTES,
MAX_SEARCH_QUERY_UNICODE_SCALARS, MAX_SEARCH_TOTAL_BYTES, MAX_SEARCH_VISITED_ENTRIES,
};
pub use read_text::{
@@ -62,6 +63,12 @@ pub const SEARCH_TEXT_CAPABILITY_ID: &str = "xgeny.fs/search-text";
pub const SEARCH_TEXT_CONTRACT_VERSION: &str = "1.0.0";
/// Exact operation name used in the root-bound search Instance.
pub const SEARCH_TEXT_OPERATION_REF: &str = "searchText";
+/// Exact public Capability identifier for one bounded line range of a UTF-8 text file.
+pub const READ_TEXT_LINES_CAPABILITY_ID: &str = "xgeny.fs/read-text-lines";
+/// Exact immutable read-text-lines contract version.
+pub const READ_TEXT_LINES_CONTRACT_VERSION: &str = "1.0.0";
+/// Exact operation name used in the root-bound line-range read Instance.
+pub const READ_TEXT_LINES_OPERATION_REF: &str = "readTextLines";
/// Exact public Capability identifier for one atomic UTF-8 file replacement.
pub const WRITE_ATOMIC_CAPABILITY_ID: &str = "xgeny.fs/write-atomic";
/// Exact immutable write-atomic contract version.
@@ -206,6 +213,12 @@ impl WorkspaceRoot {
workspace_binding(&self.workspace_id, SEARCH_TEXT_OPERATION_REF)
}
+ /// Return the exact root-bound binding for the read-text-lines adapter.
+ #[must_use]
+ pub fn read_text_lines_binding(&self) -> InstanceBinding {
+ workspace_binding(&self.workspace_id, READ_TEXT_LINES_OPERATION_REF)
+ }
+
/// Return the exact root-bound binding for atomic writes.
#[must_use]
pub fn write_atomic_binding(&self) -> InstanceBinding {
@@ -236,6 +249,12 @@ impl WorkspaceRoot {
FilesystemQueryAdapter::search_text(self.clone())
}
+ /// Construct the bounded line-range text read adapter.
+ #[must_use]
+ pub fn read_text_lines_adapter(&self) -> FilesystemQueryAdapter {
+ FilesystemQueryAdapter::read_text_lines(self.clone())
+ }
+
/// Construct the bounded atomic UTF-8 write adapter.
#[must_use]
pub fn write_atomic_adapter(&self) -> WriteAtomicAdapter {
diff --git a/crates/xgen-adapter-filesystem/src/query.rs b/crates/xgen-adapter-filesystem/src/query.rs
index 64bd52b..63d7ab3 100644
--- a/crates/xgen-adapter-filesystem/src/query.rs
+++ b/crates/xgen-adapter-filesystem/src/query.rs
@@ -18,8 +18,9 @@ use xgen_workgraph::EffectClass;
use crate::path::{RelativePath, canonical_resource, parse_canonical};
use crate::{
- LIST_DIRECTORY_CAPABILITY_ID, LIST_DIRECTORY_CONTRACT_VERSION, SEARCH_TEXT_CAPABILITY_ID,
- SEARCH_TEXT_CONTRACT_VERSION, STAT_CAPABILITY_ID, STAT_CONTRACT_VERSION, WorkspaceRoot,
+ LIST_DIRECTORY_CAPABILITY_ID, LIST_DIRECTORY_CONTRACT_VERSION, READ_TEXT_LINES_CAPABILITY_ID,
+ READ_TEXT_LINES_CONTRACT_VERSION, SEARCH_TEXT_CAPABILITY_ID, SEARCH_TEXT_CONTRACT_VERSION,
+ STAT_CAPABILITY_ID, STAT_CONTRACT_VERSION, WorkspaceRoot,
};
/// Maximum entries returned by one list-directory observation.
@@ -40,7 +41,13 @@ pub const MAX_SEARCH_FILE_BYTES: usize = 256 * 1024;
pub const MAX_SEARCH_TOTAL_BYTES: usize = 8 * 1024 * 1024;
/// Maximum UTF-8 bytes retained in one match preview.
pub const MAX_SEARCH_PREVIEW_BYTES: usize = 512;
-/// Maximum canonical JCS bytes emitted by one list/stat/search tool output, including its digest.
+/// Maximum lines requested by one read-text-lines observation.
+pub const MAX_READ_TEXT_LINES_COUNT: u64 = 2_000;
+/// Maximum file size scanned by one read-text-lines observation.
+pub const MAX_READ_TEXT_LINES_FILE_BYTES: usize = 8 * 1024 * 1024;
+/// Maximum JSON-escaped bytes of the content returned by one read-text-lines observation.
+pub const MAX_READ_TEXT_LINES_CONTENT_BYTES: usize = 64 * 1024;
+/// Maximum canonical JCS bytes emitted by one query tool output, including its digest.
pub const MAX_QUERY_OUTPUT_CANONICAL_BYTES: usize = 128 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -48,6 +55,7 @@ enum QueryOperation {
ListDirectory,
Stat,
SearchText,
+ ReadTextLines,
}
impl QueryOperation {
@@ -56,6 +64,7 @@ impl QueryOperation {
Self::ListDirectory => LIST_DIRECTORY_CAPABILITY_ID,
Self::Stat => STAT_CAPABILITY_ID,
Self::SearchText => SEARCH_TEXT_CAPABILITY_ID,
+ Self::ReadTextLines => READ_TEXT_LINES_CAPABILITY_ID,
}
}
@@ -64,6 +73,7 @@ impl QueryOperation {
Self::ListDirectory => LIST_DIRECTORY_CONTRACT_VERSION,
Self::Stat => STAT_CONTRACT_VERSION,
Self::SearchText => SEARCH_TEXT_CONTRACT_VERSION,
+ Self::ReadTextLines => READ_TEXT_LINES_CONTRACT_VERSION,
}
}
@@ -72,6 +82,7 @@ impl QueryOperation {
Self::ListDirectory => "list-directory-output",
Self::Stat => "stat-output",
Self::SearchText => "search-text-output",
+ Self::ReadTextLines => "read-text-lines-output",
}
}
@@ -80,6 +91,23 @@ impl QueryOperation {
Self::ListDirectory => "xgeny.fs/list-directory/failure/v1",
Self::Stat => "xgeny.fs/stat/failure/v1",
Self::SearchText => "xgeny.fs/search-text/failure/v1",
+ Self::ReadTextLines => "xgeny.fs/read-text-lines/failure/v1",
+ }
+ }
+
+ const fn argument_count(self) -> usize {
+ match self {
+ Self::ListDirectory | Self::Stat => 1,
+ Self::SearchText => 2,
+ Self::ReadTextLines => 3,
+ }
+ }
+
+ /// Output fields including the appended digest.
+ const fn output_field_count(self) -> usize {
+ match self {
+ Self::ListDirectory | Self::Stat | Self::SearchText => 3,
+ Self::ReadTextLines => 6,
}
}
}
@@ -113,6 +141,13 @@ impl FilesystemQueryAdapter {
}
}
+ pub(crate) const fn read_text_lines(root: WorkspaceRoot) -> Self {
+ Self {
+ root,
+ operation: QueryOperation::ReadTextLines,
+ }
+ }
+
/// Construct a verifier pinned to the same workspace and exact operation binding.
#[must_use]
pub fn verifier(&self) -> FilesystemQueryVerifier {
@@ -147,6 +182,7 @@ impl EffectAdapter for FilesystemQueryAdapter {
directory: Arc::clone(&self.root.directory),
relative_path: arguments.path,
query: arguments.query,
+ lines: arguments.lines,
operation: self.operation,
}))
}
@@ -242,6 +278,7 @@ struct PreparedFilesystemQuery {
directory: Arc
,
relative_path: RelativePath,
query: Option,
+ lines: Option,
operation: QueryOperation,
}
@@ -252,6 +289,7 @@ impl std::fmt::Debug for PreparedFilesystemQuery {
.field("directory", &"")
.field("relative_path", &"")
.field("query", &self.query.as_ref().map(|_| ""))
+ .field("lines", &self.lines)
.field("operation", &self.operation)
.finish()
}
@@ -269,6 +307,12 @@ impl PreparedAdapterInvocation for PreparedFilesystemQuery {
.as_deref()
.expect("search preparation always retains a query"),
),
+ QueryOperation::ReadTextLines => read_text_lines(
+ &self.directory,
+ &self.relative_path,
+ self.lines
+ .expect("line-range preparation always retains a range"),
+ ),
};
match observation.and_then(ObservedOutput::finish) {
Ok(observation) => AdapterExecutionObservation::SucceededWithOutput {
@@ -289,6 +333,14 @@ impl PreparedAdapterInvocation for PreparedFilesystemQuery {
struct ParsedArguments {
path: RelativePath,
query: Option,
+ lines: Option,
+}
+
+/// One-based first line and requested line count of a read-text-lines observation.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+struct LineRange {
+ start_line: u64,
+ line_count: u64,
}
struct ObservedOutput {
@@ -394,6 +446,7 @@ enum QueryFailure {
TooManyEntries,
Limit,
Encode,
+ Decode,
}
impl QueryFailure {
@@ -404,6 +457,7 @@ impl QueryFailure {
Self::TooManyEntries => "too-many-entries",
Self::Limit => "limit",
Self::Encode => "encode",
+ Self::Decode => "decode",
};
sha256_digest(format!("{}/{code}", operation.failure_domain()).as_bytes())
}
@@ -414,6 +468,7 @@ fn binding_for(root: &WorkspaceRoot, operation: QueryOperation) -> xgen_domain::
QueryOperation::ListDirectory => root.list_directory_binding(),
QueryOperation::Stat => root.stat_binding(),
QueryOperation::SearchText => root.search_text_binding(),
+ QueryOperation::ReadTextLines => root.read_text_lines_binding(),
}
}
@@ -491,14 +546,7 @@ fn parse_arguments(
) -> Result {
let object = arguments
.as_object()
- .filter(|object| {
- object.len()
- == if operation == QueryOperation::SearchText {
- 2
- } else {
- 1
- }
- })
+ .filter(|object| object.len() == operation.argument_count())
.ok_or(AdapterPrepareFailure::InvalidMaterial)?;
let path = object
.get("path")
@@ -521,7 +569,25 @@ fn parse_arguments(
} else {
None
};
- Ok(ParsedArguments { path, query })
+ let lines = if operation == QueryOperation::ReadTextLines {
+ let start_line = object
+ .get("startLine")
+ .and_then(Value::as_u64)
+ .filter(|line| *line >= 1)
+ .ok_or(AdapterPrepareFailure::InvalidMaterial)?;
+ let line_count = object
+ .get("lineCount")
+ .and_then(Value::as_u64)
+ .filter(|count| (1..=MAX_READ_TEXT_LINES_COUNT).contains(count))
+ .ok_or(AdapterPrepareFailure::InvalidMaterial)?;
+ Some(LineRange {
+ start_line,
+ line_count,
+ })
+ } else {
+ None
+ };
+ Ok(ParsedArguments { path, query, lines })
}
fn list_directory(root: &Dir, path: &RelativePath) -> Result {
@@ -630,6 +696,93 @@ fn search_text(
bounded_collection_output("matches", state.matches, state.truncated)
}
+fn read_text_lines(
+ root: &Dir,
+ path: &RelativePath,
+ range: LineRange,
+) -> Result {
+ let mut file = open_regular_file(root, path)?;
+ let metadata = file.metadata().map_err(|_| QueryFailure::Open)?;
+ if metadata.len() > u64::try_from(MAX_READ_TEXT_LINES_FILE_BYTES).expect("limit fits u64") {
+ return Err(QueryFailure::Limit);
+ }
+ let mut bytes = Vec::with_capacity(
+ usize::try_from(metadata.len())
+ .unwrap_or(MAX_READ_TEXT_LINES_FILE_BYTES)
+ .min(MAX_READ_TEXT_LINES_FILE_BYTES)
+ .saturating_add(1),
+ );
+ file.by_ref()
+ .take(u64::try_from(MAX_READ_TEXT_LINES_FILE_BYTES + 1).expect("read limit fits u64"))
+ .read_to_end(&mut bytes)
+ .map_err(|_| QueryFailure::Read)?;
+ if bytes.len() > MAX_READ_TEXT_LINES_FILE_BYTES {
+ return Err(QueryFailure::Limit);
+ }
+ let content = String::from_utf8(bytes).map_err(|_| QueryFailure::Decode)?;
+ line_range_output(&content, range)
+}
+
+/// Select whole lines, each with its original terminator, so consecutive ranges concatenate to
+/// the file. Selection stops before the JSON-escaped content would exceed its byte limit; a first
+/// line that alone exceeds it is cut at a character boundary. Either case sets `truncated`.
+fn line_range_output(content: &str, range: LineRange) -> Result {
+ let total_lines =
+ u64::try_from(content.split_inclusive('\n').count()).map_err(|_| QueryFailure::Limit)?;
+ let skip = usize::try_from(range.start_line - 1).unwrap_or(usize::MAX);
+ let take = usize::try_from(range.line_count).map_err(|_| QueryFailure::Limit)?;
+ let mut selected = String::new();
+ let mut selected_size = 0_usize;
+ let mut end_line = range.start_line - 1;
+ let mut truncated = false;
+ for line in content.split_inclusive('\n').skip(skip).take(take) {
+ let line_size = json_escaped_size(line);
+ if selected_size + line_size <= MAX_READ_TEXT_LINES_CONTENT_BYTES {
+ selected.push_str(line);
+ selected_size += line_size;
+ end_line += 1;
+ continue;
+ }
+ truncated = true;
+ if selected.is_empty() {
+ let mut cut = 0;
+ for (index, character) in line.char_indices() {
+ let size = json_escaped_char_size(character);
+ if selected_size + size > MAX_READ_TEXT_LINES_CONTENT_BYTES {
+ break;
+ }
+ selected_size += size;
+ cut = index + character.len_utf8();
+ }
+ selected.push_str(&line[..cut]);
+ end_line += 1;
+ }
+ break;
+ }
+ Ok(ObservedOutput {
+ payload: json!({
+ "content": selected,
+ "startLine": range.start_line,
+ "endLine": end_line,
+ "totalLines": total_lines,
+ "truncated": truncated,
+ }),
+ })
+}
+
+fn json_escaped_size(text: &str) -> usize {
+ text.chars().map(json_escaped_char_size).sum()
+}
+
+/// Size of one character inside an RFC 8785 JSON string.
+const fn json_escaped_char_size(character: char) -> usize {
+ match character {
+ '"' | '\\' | '\u{8}' | '\u{c}' | '\n' | '\r' | '\t' => 2,
+ '\u{0}'..='\u{1f}' => 6,
+ _ => character.len_utf8(),
+ }
+}
+
fn search_match_key(candidate: &Value) -> (&str, u64, u64) {
(
candidate["path"]
@@ -878,7 +1031,7 @@ fn inspect_output(
}
let object = output
.as_object()
- .filter(|object| object.len() == 3)
+ .filter(|object| object.len() == operation.output_field_count())
.ok_or(VerificationPortFailure::ResponseUnverifiable)?;
let claimed = object
.get("digest")
@@ -909,9 +1062,44 @@ fn validate_output_shape(
QueryOperation::ListDirectory => validate_list_output(object, workspace_id),
QueryOperation::Stat => validate_stat_output(object),
QueryOperation::SearchText => validate_search_output(object, workspace_id),
+ QueryOperation::ReadTextLines => validate_lines_output(object),
}
}
+fn validate_lines_output(object: &Map) -> Result<(), VerificationPortFailure> {
+ let content = object
+ .get("content")
+ .and_then(Value::as_str)
+ .filter(|content| json_escaped_size(content) <= MAX_READ_TEXT_LINES_CONTENT_BYTES)
+ .ok_or(VerificationPortFailure::ResponseUnverifiable)?;
+ let start_line = object
+ .get("startLine")
+ .and_then(Value::as_u64)
+ .filter(|line| *line >= 1)
+ .ok_or(VerificationPortFailure::ResponseUnverifiable)?;
+ let end_line = object
+ .get("endLine")
+ .and_then(Value::as_u64)
+ .ok_or(VerificationPortFailure::ResponseUnverifiable)?;
+ let total_lines = object
+ .get("totalLines")
+ .and_then(Value::as_u64)
+ .ok_or(VerificationPortFailure::ResponseUnverifiable)?;
+ object
+ .get("truncated")
+ .and_then(Value::as_bool)
+ .ok_or(VerificationPortFailure::ResponseUnverifiable)?;
+ let returned = u64::try_from(content.split_inclusive('\n').count())
+ .map_err(|_| VerificationPortFailure::ResponseUnverifiable)?;
+ if returned > MAX_READ_TEXT_LINES_COUNT
+ || end_line.checked_add(1) != start_line.checked_add(returned)
+ || (returned > 0 && end_line > total_lines)
+ {
+ return Err(VerificationPortFailure::ResponseUnverifiable);
+ }
+ Ok(())
+}
+
fn validate_list_output(
object: &Map,
workspace_id: &crate::WorkspaceId,
@@ -1273,6 +1461,254 @@ mod tests {
assert!(state.scanned_bytes <= "second".len());
}
+ fn lines(start_line: u64, line_count: u64) -> LineRange {
+ LineRange {
+ start_line,
+ line_count,
+ }
+ }
+
+ #[test]
+ fn line_ranges_keep_terminators_and_concatenate_to_the_file() {
+ let fixture = Fixture::new();
+ let text = "one\r\ntwo\n\nfour\nfive";
+ fs::write(fixture.directory.path().join("file.txt"), text).unwrap();
+ let path = fixture.relative("file.txt");
+
+ let first = read_text_lines(&fixture.root.directory, &path, lines(1, 2))
+ .unwrap()
+ .finish()
+ .unwrap()
+ .output;
+ let rest = read_text_lines(&fixture.root.directory, &path, lines(3, 10))
+ .unwrap()
+ .finish()
+ .unwrap()
+ .output;
+
+ assert_eq!(first["content"], "one\r\ntwo\n");
+ assert_eq!(first["startLine"], 1);
+ assert_eq!(first["endLine"], 2);
+ assert_eq!(first["totalLines"], 5);
+ assert_eq!(first["truncated"], false);
+ assert_eq!(rest["content"], "\nfour\nfive");
+ assert_eq!(rest["endLine"], 5);
+ assert_eq!(rest["truncated"], false);
+ assert_eq!(
+ format!(
+ "{}{}",
+ first["content"].as_str().unwrap(),
+ rest["content"].as_str().unwrap()
+ ),
+ text
+ );
+ }
+
+ #[test]
+ fn a_range_past_the_end_is_empty_and_reports_the_line_count() {
+ let fixture = Fixture::new();
+ fs::write(fixture.directory.path().join("file.txt"), "a\nb\n").unwrap();
+ fs::write(fixture.directory.path().join("empty.txt"), "").unwrap();
+
+ let past = read_text_lines(
+ &fixture.root.directory,
+ &fixture.relative("file.txt"),
+ lines(9, 5),
+ )
+ .unwrap()
+ .finish()
+ .unwrap();
+ let empty = read_text_lines(
+ &fixture.root.directory,
+ &fixture.relative("empty.txt"),
+ lines(1, 5),
+ )
+ .unwrap()
+ .finish()
+ .unwrap()
+ .output;
+
+ assert_eq!(past.output["content"], "");
+ assert_eq!(past.output["endLine"], 8);
+ assert_eq!(past.output["totalLines"], 2);
+ assert!(
+ inspect_output(
+ &past.output,
+ &past.digest,
+ &fixture.root.workspace_id,
+ QueryOperation::ReadTextLines,
+ )
+ .unwrap()
+ .postcondition
+ );
+ assert_eq!(empty["content"], "");
+ assert_eq!(empty["endLine"], 0);
+ assert_eq!(empty["totalLines"], 0);
+ }
+
+ #[test]
+ fn line_ranges_stop_at_the_escaped_content_limit() {
+ let fixture = Fixture::new();
+ // Each line escapes to 1_024 bytes: 1_020 plain characters, a quote, and a newline.
+ let line = format!("{}\"\n", "x".repeat(1_020));
+ fs::write(fixture.directory.path().join("wide.txt"), line.repeat(100)).unwrap();
+ let control_line = "\u{1}".repeat(MAX_READ_TEXT_LINES_CONTENT_BYTES);
+ fs::write(
+ fixture.directory.path().join("long.txt"),
+ format!("{control_line}\nnext\n"),
+ )
+ .unwrap();
+
+ let wide = read_text_lines(
+ &fixture.root.directory,
+ &fixture.relative("wide.txt"),
+ lines(1, 100),
+ )
+ .unwrap()
+ .finish()
+ .unwrap();
+ let long = read_text_lines(
+ &fixture.root.directory,
+ &fixture.relative("long.txt"),
+ lines(1, 2),
+ )
+ .unwrap()
+ .finish()
+ .unwrap();
+
+ assert_eq!(wide.output["endLine"], 64);
+ assert_eq!(wide.output["totalLines"], 100);
+ assert_eq!(wide.output["truncated"], true);
+ assert_eq!(long.output["endLine"], 1);
+ assert_eq!(long.output["truncated"], true);
+ assert_eq!(
+ long.output["content"].as_str().unwrap().chars().count(),
+ MAX_READ_TEXT_LINES_CONTENT_BYTES / 6
+ );
+ for finished in [&wide, &long] {
+ assert!(
+ serde_jcs::to_vec(&finished.output).unwrap().len()
+ <= MAX_QUERY_OUTPUT_CANONICAL_BYTES
+ );
+ assert!(
+ inspect_output(
+ &finished.output,
+ &finished.digest,
+ &fixture.root.workspace_id,
+ QueryOperation::ReadTextLines,
+ )
+ .unwrap()
+ .postcondition
+ );
+ }
+ }
+
+ #[test]
+ fn line_ranges_read_files_past_the_whole_file_limit_but_fail_closed_beyond_the_scan_limit() {
+ let fixture = Fixture::new();
+ let large = "line\n".repeat(20_000);
+ fs::write(fixture.directory.path().join("large.txt"), &large).unwrap();
+ fs::write(
+ fixture.directory.path().join("huge.txt"),
+ vec![b'x'; MAX_READ_TEXT_LINES_FILE_BYTES + 1],
+ )
+ .unwrap();
+ fs::write(fixture.directory.path().join("binary.bin"), [0xff, 0xfe]).unwrap();
+
+ let tail = read_text_lines(
+ &fixture.root.directory,
+ &fixture.relative("large.txt"),
+ lines(19_999, 5),
+ )
+ .unwrap()
+ .finish()
+ .unwrap()
+ .output;
+
+ assert!(large.len() > crate::MAX_READ_TEXT_BYTES);
+ assert_eq!(tail["content"], "line\nline\n");
+ assert_eq!(tail["totalLines"], 20_000);
+ assert!(matches!(
+ read_text_lines(
+ &fixture.root.directory,
+ &fixture.relative("huge.txt"),
+ lines(1, 1)
+ ),
+ Err(QueryFailure::Limit)
+ ));
+ assert!(matches!(
+ read_text_lines(
+ &fixture.root.directory,
+ &fixture.relative("binary.bin"),
+ lines(1, 1)
+ ),
+ Err(QueryFailure::Decode)
+ ));
+ assert!(matches!(
+ read_text_lines(&fixture.root.directory, &fixture.relative("."), lines(1, 1)),
+ Err(QueryFailure::Open)
+ ));
+ }
+
+ #[test]
+ fn line_range_parser_requires_a_bounded_one_based_range() {
+ let fixture = Fixture::new();
+ let parsed = parse_arguments(
+ &json!({"path": "workspace:fixture/a.txt", "startLine": 3, "lineCount": 2000}),
+ &fixture.root,
+ QueryOperation::ReadTextLines,
+ )
+ .unwrap();
+ assert_eq!(parsed.lines, Some(lines(3, 2_000)));
+ for arguments in [
+ json!({"path": "workspace:fixture/a.txt"}),
+ json!({"path": "workspace:fixture/a.txt", "startLine": 0, "lineCount": 1}),
+ json!({"path": "workspace:fixture/a.txt", "startLine": 1, "lineCount": 0}),
+ json!({"path": "workspace:fixture/a.txt", "startLine": 1, "lineCount": 2001}),
+ json!({"path": "workspace:fixture/a.txt", "startLine": 1.5, "lineCount": 1}),
+ json!({"path": "workspace:fixture/a.txt", "startLine": 1, "lineCount": 1, "x": 1}),
+ ] {
+ assert_eq!(
+ parse_arguments(&arguments, &fixture.root, QueryOperation::ReadTextLines)
+ .map(|_| ()),
+ Err(AdapterPrepareFailure::InvalidMaterial)
+ );
+ }
+ }
+
+ #[test]
+ fn line_range_inspection_rejects_inconsistent_line_numbers() {
+ let fixture = Fixture::new();
+ fs::write(fixture.directory.path().join("file.txt"), "a\nb\nc\n").unwrap();
+ let finished = read_text_lines(
+ &fixture.root.directory,
+ &fixture.relative("file.txt"),
+ lines(2, 1),
+ )
+ .unwrap()
+ .finish()
+ .unwrap();
+ for (field, value) in [
+ ("endLine", json!(3)),
+ ("startLine", json!(0)),
+ ("totalLines", json!(1)),
+ ("content", json!("b\nc\n")),
+ ] {
+ let mut tampered = finished.output.clone();
+ tampered[field] = value;
+ assert!(
+ inspect_output(
+ &tampered,
+ &finished.digest,
+ &fixture.root.workspace_id,
+ QueryOperation::ReadTextLines,
+ )
+ .is_err(),
+ "{field} tampering must fail closed"
+ );
+ }
+ }
+
#[cfg(unix)]
#[test]
fn queries_never_follow_workspace_symlinks() {
@@ -1293,6 +1729,14 @@ mod tests {
)
.is_err()
);
+ assert!(
+ read_text_lines(
+ &fixture.root.directory,
+ &fixture.relative("linked/secret.txt"),
+ lines(1, 1)
+ )
+ .is_err()
+ );
let root = list_directory(&fixture.root.directory, &fixture.relative("."))
.unwrap()
.finish()
diff --git a/crates/xgen-cli/src/allow_path.rs b/crates/xgen-cli/src/allow_path.rs
index 8510dca..5156773 100644
--- a/crates/xgen-cli/src/allow_path.rs
+++ b/crates/xgen-cli/src/allow_path.rs
@@ -8,8 +8,9 @@ use thiserror::Error;
use xgen_adapter_filesystem::{
APPLY_PATCH_CAPABILITY_ID, APPLY_PATCH_CONTRACT_VERSION, FILESYSTEM_READ_SCOPE,
LIST_DIRECTORY_CAPABILITY_ID, LIST_DIRECTORY_CONTRACT_VERSION, MAX_APPLY_PATCH_BYTES,
- MAX_APPLY_PATCH_EDITS, MAX_SEARCH_QUERY_BYTES, MAX_SEARCH_QUERY_UNICODE_SCALARS,
- MAX_WRITE_ATOMIC_BYTES, READ_TEXT_CAPABILITY_ID, READ_TEXT_CONTRACT_VERSION,
+ MAX_APPLY_PATCH_EDITS, MAX_READ_TEXT_LINES_COUNT, MAX_SEARCH_QUERY_BYTES,
+ MAX_SEARCH_QUERY_UNICODE_SCALARS, MAX_WRITE_ATOMIC_BYTES, READ_TEXT_CAPABILITY_ID,
+ READ_TEXT_CONTRACT_VERSION, READ_TEXT_LINES_CAPABILITY_ID, READ_TEXT_LINES_CONTRACT_VERSION,
SEARCH_TEXT_CAPABILITY_ID, SEARCH_TEXT_CONTRACT_VERSION, STAT_CAPABILITY_ID,
STAT_CONTRACT_VERSION, WRITE_ATOMIC_CAPABILITY_ID, WRITE_ATOMIC_CONTRACT_VERSION,
WorkspaceResourceResolver,
@@ -102,6 +103,18 @@ impl WorkspaceReadAuthorization {
| (STAT_CAPABILITY_ID, STAT_CONTRACT_VERSION) => {
object.len() == 1 && self.authorizes_file_or_descendant(path)
}
+ (READ_TEXT_LINES_CAPABILITY_ID, READ_TEXT_LINES_CONTRACT_VERSION) => {
+ object.len() == 3
+ && object
+ .get("startLine")
+ .and_then(Value::as_u64)
+ .is_some_and(|line| line >= 1)
+ && object
+ .get("lineCount")
+ .and_then(Value::as_u64)
+ .is_some_and(|count| (1..=MAX_READ_TEXT_LINES_COUNT).contains(&count))
+ && self.authorizes_file_or_descendant(path)
+ }
(LIST_DIRECTORY_CAPABILITY_ID, LIST_DIRECTORY_CONTRACT_VERSION) => {
object.len() == 1 && self.authorizes_directory_or_descendant(path)
}
@@ -137,6 +150,7 @@ impl WorkspaceReadAuthorization {
capability.contract_version.as_str(),
) {
(READ_TEXT_CAPABILITY_ID, READ_TEXT_CONTRACT_VERSION)
+ | (READ_TEXT_LINES_CAPABILITY_ID, READ_TEXT_LINES_CONTRACT_VERSION)
| (STAT_CAPABILITY_ID, STAT_CONTRACT_VERSION) => {
self.authorizes_file_or_descendant(resource)
}
@@ -438,6 +452,27 @@ mod tests {
);
}
+ #[test]
+ fn line_range_reads_follow_file_scope_and_a_bounded_range() {
+ let catalog = authorization(&["big.py"], &["src"]);
+ let lines = capability(READ_TEXT_LINES_CAPABILITY_ID);
+ for path in ["workspace:fixture/big.py", "workspace:fixture/src/lib.rs"] {
+ assert!(catalog.authorizes_material(
+ &lines,
+ &json!({"path": path, "startLine": 1, "lineCount": 2000}),
+ ));
+ }
+ assert!(catalog.authorizes_resource(&lines, "workspace:fixture/big.py"));
+ for arguments in [
+ json!({"path": "workspace:fixture/other.py", "startLine": 1, "lineCount": 10}),
+ json!({"path": "workspace:fixture/big.py", "startLine": 0, "lineCount": 10}),
+ json!({"path": "workspace:fixture/big.py", "startLine": 1, "lineCount": 2001}),
+ json!({"path": "workspace:fixture/big.py", "startLine": 1}),
+ ] {
+ assert!(!catalog.authorizes_material(&lines, &arguments));
+ }
+ }
+
#[test]
fn write_material_is_bounded_and_requires_a_directory_scope() {
let catalog = authorization(&["README.md"], &["src"]);
diff --git a/crates/xgen-cli/src/composition.rs b/crates/xgen-cli/src/composition.rs
index 4f0d076..39fa87a 100644
--- a/crates/xgen-cli/src/composition.rs
+++ b/crates/xgen-cli/src/composition.rs
@@ -11,10 +11,12 @@ use xgen_adapter_filesystem::{
APPLY_PATCH_CAPABILITY_ID, APPLY_PATCH_CONTRACT_VERSION, FILESYSTEM_READ_SCOPE,
FILESYSTEM_WRITE_SCOPE, LIST_DIRECTORY_CAPABILITY_ID, LIST_DIRECTORY_CONTRACT_VERSION,
MAX_APPLY_PATCH_BYTES, MAX_APPLY_PATCH_EDITS, MAX_DIRECTORY_SCAN_ENTRIES,
- MAX_LIST_DIRECTORY_ENTRIES, MAX_QUERY_OUTPUT_CANONICAL_BYTES, MAX_SEARCH_FILE_BYTES,
- MAX_SEARCH_MATCHES, MAX_SEARCH_PREVIEW_BYTES, MAX_SEARCH_QUERY_BYTES,
+ MAX_LIST_DIRECTORY_ENTRIES, MAX_QUERY_OUTPUT_CANONICAL_BYTES,
+ MAX_READ_TEXT_LINES_CONTENT_BYTES, MAX_READ_TEXT_LINES_COUNT, MAX_READ_TEXT_LINES_FILE_BYTES,
+ MAX_SEARCH_FILE_BYTES, MAX_SEARCH_MATCHES, MAX_SEARCH_PREVIEW_BYTES, MAX_SEARCH_QUERY_BYTES,
MAX_SEARCH_QUERY_UNICODE_SCALARS, MAX_SEARCH_TOTAL_BYTES, MAX_SEARCH_VISITED_ENTRIES,
- MAX_WRITE_ATOMIC_BYTES, READ_TEXT_CAPABILITY_ID, READ_TEXT_CONTRACT_VERSION, ReadTextLimits,
+ MAX_WRITE_ATOMIC_BYTES, READ_TEXT_CAPABILITY_ID, READ_TEXT_CONTRACT_VERSION,
+ READ_TEXT_LINES_CAPABILITY_ID, READ_TEXT_LINES_CONTRACT_VERSION, ReadTextLimits,
SEARCH_TEXT_CAPABILITY_ID, SEARCH_TEXT_CONTRACT_VERSION, STAT_CAPABILITY_ID,
STAT_CONTRACT_VERSION, WRITE_ATOMIC_CAPABILITY_ID, WRITE_ATOMIC_CONTRACT_VERSION, WorkspaceId,
WorkspaceResourceResolver, WorkspaceRoot,
@@ -2441,6 +2443,9 @@ struct DiscoveryLimitsDescriptor {
search_file_bytes: usize,
search_total_bytes: usize,
search_preview_bytes: usize,
+ read_text_lines_count: u64,
+ read_text_lines_file_bytes: usize,
+ read_text_lines_content_bytes: usize,
query_output_canonical_bytes: usize,
}
@@ -2616,6 +2621,9 @@ fn workspace_discovery_profile_digest(workspace: &WorkspaceRoot) -> Result 64 * 1024);
+ fs::write(workspace.join("large.py"), &large).expect("large file should write");
+ let server = SequentialServer::spawn_responses(vec![
+ plan_response(
+ "read_tail",
+ "Read the definition at the end of the large file",
+ "xgeny.fs/read-text-lines",
+ &json!({"path": "large.py", "startLine": 3_001, "lineCount": 50}),
+ ),
+ completion_response(),
+ ]);
+
+ let output = bounded_output(xgen(&state_root).args([
+ "run",
+ "--workspace",
+ path_text(&workspace),
+ "--base-url",
+ &server.base_url,
+ "--model",
+ MODEL,
+ "--tokenizer",
+ TOKENIZER,
+ "--allow-dir",
+ ".",
+ "--allow-read",
+ "--allow-remote-model-egress",
+ "What does target return?",
+ ]))
+ .expect("xgen run should finish");
+ assert_eq!(output.status.code(), Some(0), "{}", stderr(&output));
+
+ let requests = (0..2)
+ .map(|_| {
+ server
+ .requests
+ .recv_timeout(TEST_TIMEOUT)
+ .expect("every model request should arrive")
+ })
+ .collect::>();
+ server.handle.join().expect("provider server should finish");
+
+ let first = planning_context(&requests[0]);
+ let lines = capability(&first, "xgeny.fs/read-text-lines");
+ assert_eq!(lines["effectClass"], "read_only");
+ let after_read = planning_context(&requests[1]);
+ let observed = tool_output(&after_read, "xgeny.fs/read-text-lines");
+ assert_eq!(observed["content"], "def target():\n return 42\n");
+ assert_eq!(observed["startLine"], 3_001);
+ assert_eq!(observed["endLine"], 3_002);
+ assert_eq!(observed["totalLines"], 3_002);
+ assert_eq!(observed["truncated"], false);
+}
+
#[test]
fn failed_write_still_stops_the_run() {
let fixture = tempdir().expect("test directory should exist");
@@ -1069,12 +1130,13 @@ fn public_cli_discovers_searches_stats_reads_and_replays_offline() {
assert!(first_system_prompt.contains("always set dependsOn to an empty array"));
assert!(first_system_prompt.contains("durable plan chronology"));
assert!(first_system_prompt.contains("durable receipt-completion chronology"));
- assert_eq!(first["capabilities"].as_array().unwrap().len(), 6);
+ assert_eq!(first["capabilities"].as_array().unwrap().len(), 7);
assert_eq!(first["toolOutputs"], json!([]));
assert!(capability_ids(&first).contains(&"xgeny.fs/list-directory"));
assert!(capability_ids(&first).contains(&"xgeny.fs/search-text"));
assert!(capability_ids(&first).contains(&"xgeny.fs/stat"));
assert!(capability_ids(&first).contains(&"xgeny.fs/read-text"));
+ assert!(capability_ids(&first).contains(&"xgeny.fs/read-text-lines"));
assert!(capability_ids(&first).contains(&"xgeny.fs/write-atomic"));
assert!(capability_ids(&first).contains(&"xgeny.fs/apply-patch"));
let path_description = capability(&first, "xgeny.fs/list-directory")["inputSchema"]
diff --git a/crates/xgen-protocol/src/assets.rs b/crates/xgen-protocol/src/assets.rs
index 16677a6..44a7fbc 100644
--- a/crates/xgen-protocol/src/assets.rs
+++ b/crates/xgen-protocol/src/assets.rs
@@ -67,6 +67,10 @@ pub(crate) const FIXTURES: &[Asset] = &[
"valid/capability-definition.fs-read-text.json",
"../../../protocol/fixtures/v1alpha1/valid/capability-definition.fs-read-text.json"
),
+ asset!(
+ "valid/capability-definition.fs-read-text-lines.json",
+ "../../../protocol/fixtures/v1alpha1/valid/capability-definition.fs-read-text-lines.json"
+ ),
asset!(
"valid/capability-definition.fs-search-text.json",
"../../../protocol/fixtures/v1alpha1/valid/capability-definition.fs-search-text.json"
diff --git a/crates/xgen-protocol/src/lib.rs b/crates/xgen-protocol/src/lib.rs
index 09f63f9..60d75fd 100644
--- a/crates/xgen-protocol/src/lib.rs
+++ b/crates/xgen-protocol/src/lib.rs
@@ -850,8 +850,8 @@ mod tests {
fn bundled_protocol_is_conformant() {
let report = check_bundled_protocol().expect("bundled protocol should be conformant");
assert_eq!(report.schema_count, 9);
- assert_eq!(report.fixture_count, 33);
- assert_eq!(report.valid_fixture_count, 23);
+ assert_eq!(report.fixture_count, 34);
+ assert_eq!(report.valid_fixture_count, 24);
assert_eq!(report.invalid_fixture_count, 10);
assert!(report.semantic_check_count >= 10);
}
diff --git a/docs/adr/0022-capability-confined-filesystem-read-adapter.md b/docs/adr/0022-capability-confined-filesystem-read-adapter.md
index 898bdf8..15162cf 100644
--- a/docs/adr/0022-capability-confined-filesystem-read-adapter.md
+++ b/docs/adr/0022-capability-confined-filesystem-read-adapter.md
@@ -143,7 +143,7 @@ read가 bounded이며 한 byte 초과하면 실패한다. 그 뒤 `String::from_
머물도록 선택했다. PR3의 MVP composition 회귀는 effective Run budget도 512 KiB로 설정하고 그
동일한 budget과 비교한다. 더 작은 context budget을 선택한 host에서는 유효한 큰 read가 실행된 뒤
다음 planning turn 직전에 `ContextBudgetExceeded`로 pause할 수 있다. 큰 파일은 상한을 높이지 않고
-후속 range/chunk capability로 다룬다.
+후속 range/chunk capability로 다룬다(줄 범위 읽기는 [ADR-0053](0053-bounded-line-range-text-read.md)).
성공 output은 기존 public Definition과 정확히 같다.
@@ -221,6 +221,7 @@ Linux, macOS와 Windows의 `cargo test --workspace --locked`가 동일 gate를
- hostile same-UID process, kernel/driver와 untrusted in-process plugin 격리
- concurrent in-place write 중 coherent/atomic file snapshot
- filesystem timeout, cancellation, streaming, binary, range/chunk read
+ (UTF-8 줄 범위 읽기는 [ADR-0053](0053-bounded-line-range-text-read.md)가 다룬다)
- case/Unicode alias의 stable physical file identity
- ReadOnly crash 뒤 자동 retry와 tool failure를 다음 model turn에 전달하는 typed error output
(확정된 read-only 실패 뒤 계획을 계속하는 것은 [ADR-0052](0052-recoverable-read-only-step-failures.md)가 다룬다. 실패 원인 분류 전달은 여전히 범위 밖이다)
diff --git a/docs/adr/0052-recoverable-read-only-step-failures.md b/docs/adr/0052-recoverable-read-only-step-failures.md
index 2dbb066..85dce1b 100644
--- a/docs/adr/0052-recoverable-read-only-step-failures.md
+++ b/docs/adr/0052-recoverable-read-only-step-failures.md
@@ -60,8 +60,8 @@ reducer 조건이 넓어지기만 하므로 기존 journal은 새 binary에서
(ADR-0015).
- 실패 원인 분류(too-large, not-found 등)는 아직 모델에 전달하지 않는다. 지금 adapter는 고정 분류의
digest만 남긴다. typed failure output은 별도 결정이 필요하다.
-- 64 KiB를 넘는 파일을 읽는 방법은 여전히 없다. ADR-0022가 예정한 range/chunk capability는 후속
- 작업이다.
+- 64 KiB를 넘는 파일을 읽는 방법은 이 ADR에 없다. 줄 범위 읽기는
+ [ADR-0053](0053-bounded-line-range-text-read.md)이 추가한다.
## 검증
diff --git a/docs/adr/0053-bounded-line-range-text-read.md b/docs/adr/0053-bounded-line-range-text-read.md
new file mode 100644
index 0000000..32d0bc6
--- /dev/null
+++ b/docs/adr/0053-bounded-line-range-text-read.md
@@ -0,0 +1,85 @@
+# ADR-0053: 큰 UTF-8 파일의 줄 범위 읽기
+
+- 상태: Accepted
+- 기준일: 2026-10-25
+- 변경 분류: 범용 filesystem capability 추가. 특정 언어·파일 형식·업무 규칙 없음.
+- 기존 경계: ADR-0022, ADR-0024, ADR-0052
+- protocol 변경: 새 CapabilityDefinition fixture 하나. 기존 Definition·schema·journal·SQLite schema는 그대로다.
+- planner prompt 변경: 없음. 새 capability는 planning context의 capability 목록으로만 전달된다.
+
+## 문제
+
+`xgeny.fs/read-text`는 64 KiB를 넘는 파일을 거절한다(ADR-0022). ADR-0022는 상한을 높이지 않고 후속
+range/chunk capability로 다루기로 했다. 실사용 시나리오에서 75 KB Python 파일 하나가 이 상한에
+걸렸다. ADR-0052 이후 요청은 더 이상 `failed_work`로 끝나지 않지만, 모델은 그 파일을 읽을 방법이
+없어 같은 실패 읽기와 검색을 반복하다 턴 예산을 소진했다. 소스 코드, 로그, 생성된 문서처럼 64 KiB를
+넘는 텍스트 파일은 흔하다.
+
+## 결정
+
+### 1. `xgeny.fs/read-text-lines@1.0.0`를 추가한다
+
+입력은 `{path, startLine, lineCount}`다. `startLine`은 1부터 세고, `lineCount`는 1–2,000이다. 추가
+필드는 거절한다. `--allow-dir`·`--allow-file` 범위는 `read-text`와 같은 규칙(exact file 또는 허용
+directory의 하위)으로 검사한다. effect class는 `read_only`이고 `--allow-read` 승인 아래 실행된다.
+
+출력은 `{content, startLine, endLine, totalLines, truncated, digest}`다.
+
+- 줄은 `\n` 기준으로 나누고, 각 줄은 원래 종결자(`\n` 또는 `\r\n`)를 그대로 포함한다. 이어지는 범위를
+ 붙이면 원래 파일과 바이트 단위로 같다. 마지막 줄 뒤의 종결자는 빈 줄을 만들지 않는다.
+- `endLine`은 마지막으로 반환한 줄이다. 줄을 하나도 반환하지 않으면(범위가 파일 끝을 넘는 경우)
+ `startLine - 1`이다. `totalLines`로 모델이 다음 범위를 고른다.
+- `digest`는 다른 workspace 조회(list-directory·stat·search-text)와 같이 digest를 뺀 출력의 RFC 8785
+ canonical 바이트에 대한 SHA-256이다. 파일 내용 digest가 아니므로 `apply-patch`의 `expectedDigest`로
+ 쓸 수 없다.
+
+### 2. 크기 상한
+
+- 파일은 8 MiB까지 읽는다. 더 크면 확정 실패다. search-text의 전체 스캔 상한과 같은 값이다.
+- 파일 전체가 UTF-8이어야 한다. 아니면 확정 실패다.
+- 반환하는 `content`는 JSON escaping 뒤 64 KiB 이하다. raw 바이트가 아니라 escape된 크기로 세므로
+ control 문자가 많은 텍스트에서도 출력 하나가 ADR-0022가 계산한 PlanningContext 여유 안에 머문다.
+- 상한에 닿으면 그 앞의 온전한 줄까지만 반환하고 `truncated: true`다. 요청 범위의 첫 줄 하나만으로
+ 상한을 넘으면 그 줄을 문자 경계에서 잘라 반환하고 역시 `truncated: true`다. 이때 그 줄의 나머지는
+ 이 capability로 읽을 수 없다. minified 파일처럼 한 줄이 64 KiB를 넘는 경우의 한계로 남긴다.
+
+### 3. 실행 의미
+
+경로는 `read-text`·search-text와 같이 symlink를 따르지 않는다. 실패는 기존 조회 adapter와 같은 고정
+분류 digest(`open`, `read`, `limit`, `decode`, `encode`)만 남긴다. 실패는 ADR-0052에 따라 recoverable
+read failure가 되고, 모델에 원인 분류는 전달되지 않는다. verifier는 digest와 함께 줄 번호 관계
+(`endLine + 1 = startLine + 반환 줄 수`, `endLine <= totalLines`)와 content 상한을 다시 검사한다.
+
+## 대안
+
+- **read-text 상한 상향:** 출력 하나가 PlanningContext 상한을 위협한다(ADR-0022). 거절했다.
+- **byte offset 범위:** UTF-8 경계와 줄 경계를 모델이 맞춰야 한다. search-text가 줄 번호를 주므로
+ 줄 범위가 바로 이어 쓰기 좋다.
+- **read-text에 선택 필드 추가:** 기존 Definition은 immutable 계약이고 digest가 저장된 Run에 묶여 있다.
+ 새 contract로 분리했다.
+
+## 결과
+
+- 64 KiB를 넘고 8 MiB 이하인 UTF-8 파일을 범위로 나눠 읽을 수 있다.
+- workspace discovery mode의 capability가 6개에서 7개가 된다. workspace discovery profile digest가
+ 바뀌므로 이 변경 이전 binary로 시작해 멈춘 workspace Run은 새 binary에서 `ConfigurationMismatch`로
+ 재개가 거절된다. 완료된 Run의 offline replay는 영향이 없다.
+- 큰 파일 수정은 여전히 불가능하다. `apply-patch`·`write-atomic`도 64 KiB 상한이다. 별도 결정이 필요하다.
+- 같은 파일을 여러 범위로 읽으면 tool output이 planning context에 쌓인다. context budget 소진은 기존
+ `ContextBudgetExceeded` 경로를 탄다.
+
+## 검증
+
+- adapter: 범위 연결이 원본과 같음, CRLF 보존, 파일 끝을 넘는 범위, 빈 파일, escape 기준 상한 절단,
+ 첫 줄 절단, 8 MiB 초과·비 UTF-8·directory 거절, symlink 비추적, 입력 shape 거절, 출력 변조 거절.
+- 권한: exact file과 허용 directory 하위만 승인하고 범위 밖 인자를 거절한다.
+- e2e: 64 KiB를 넘는 파일의 끝 범위를 public `xgen run`으로 읽고 다음 planning context에서 출력을 본다.
+- 실사용(대화형 `xgen`, 같은 planner model, 2026-10-25):
+ - 설계 사례: 75 KB·2,004줄 `server.py`가 있는 Python 저장소에서 README의 도구 목록 대조. 이전에는
+ 턴 예산 소진으로 끝났다. 이번에는 1–1,655줄(content 상한으로 `truncated`)과 1,656–2,004줄을 이어
+ 읽고 18 Step·51초에 완료했다. 다만 모델의 README 수정(중복 행 추가)과 완료 요약이 실제 변경과
+ 달랐다. capability가 아니라 planner 출력 품질 문제로 따로 다룬다.
+ - 검증 사례(설계에 쓰지 않은 저장소): 113 KB·3,296줄 HTTP executor의 기본 timeout과 HTTP 오류 처리
+ 설명. `read-text`가 두 번 실패한 뒤 search-text·stat으로 위치를 찾고 100–200줄, 360–479줄을 읽어
+ 6 Step·11초에 줄 번호까지 정확히 답했다.
+ - 두 사례 모두 같은 실패 읽기를 반복했다. 실패 원인 분류를 모델에 전달하지 않는 한계(ADR-0052)다.
diff --git a/docs/development/public-local-run-resume.md b/docs/development/public-local-run-resume.md
index f708102..f05be33 100644
--- a/docs/development/public-local-run-resume.md
+++ b/docs/development/public-local-run-resume.md
@@ -3,7 +3,7 @@
이 문서는 ADR-0023과 ADR-0024 구현을 개발·검증하는 최소 운영 절차다. ADR-0025의 mutation 명령과
별도 `--allow-write` 재개 절차는 [Workspace atomic write](workspace-atomic-write.md)를 따른다. 기존 exact-file mode는
OpenAI-compatible planner와 workspace `read-text` 하나를 유지한다. Source/main의 opt-in
-workspace mode는 `list-directory`, `stat`, `search-text`, `read-text`, `write-atomic`, `apply-patch`를
+workspace mode는 `list-directory`, `stat`, `search-text`, `read-text`, `read-text-lines`, `write-atomic`, `apply-patch`를
제공한다. 명시적 executable catalog와 별도 실행 승인은
[Process execute adapter](process-execute-adapter.md)를 따른다.
diff --git a/docs/development/rust-workspace.md b/docs/development/rust-workspace.md
index 8b7754a..afc3b60 100644
--- a/docs/development/rust-workspace.md
+++ b/docs/development/rust-workspace.md
@@ -21,7 +21,7 @@ crates/
xgen-cli/ protocol check와 public local run/resume를 제공하는 실행 파일·driver library
```
-`xgen-workgraph`, `xgen-local-store`, `xgen-runtime`의 durable effect, invocation material, Direct Executor, Core verification, VerifiedRunIndex, Persistent dependency frontier, bounded AgentLoop와 model-call lifecycle 계약은 ADR-0008·0010·0011·0012·0013·0014·0015·0016 연구 gate를 위한 내부 실험이다. ADR-0018은 내부 ReadOnly profile, Artifact-bearing Core Receipt v2와 기존 구성요소를 조합하는 bounded CLI library driver를 추가했다. ADR-0019는 event-anchored `ToolOutputRecord`와 SQLite physical schema 7을 추가했고, ADR-0020·0021은 generation-checked planning context와 durable completion을 schema 8에 연결했다. Registry와 Router는 기존 Definition/Instance를 사용한다. `xgen-policy`의 Allow는 provisional이며 runtime Admission이 exact invocation의 one-shot authority를 Run/Step/action/Instance/material에 결합한다. `xgen-adapter-reference`는 비제품 conformance 기준이다. 제품 `xgen-adapter-filesystem`은 exact-file `read-text`와 opt-in directory `list-directory`·`stat`·`search-text`·`write-atomic`·`apply-patch`를 제공한다. `xgen-adapter-process`는 host-catalogued executable을 shell 없이 bounded 실행한다. Public CLI는 기존 `--allow-file` profile을 보존하면서 `--allow-dir` discovery, 별도 `--allow-write` atomic mutation과 별도 `--allow-execute` process 승인을 제공한다. Bare `xgen` REPL은 이 composition을 재사용해 interactive 승인, durable progress와 재개를 제공한다. Network adapter, MCP, Connector와 XGEN 원격 연동은 아직 구현하지 않는다.
+`xgen-workgraph`, `xgen-local-store`, `xgen-runtime`의 durable effect, invocation material, Direct Executor, Core verification, VerifiedRunIndex, Persistent dependency frontier, bounded AgentLoop와 model-call lifecycle 계약은 ADR-0008·0010·0011·0012·0013·0014·0015·0016 연구 gate를 위한 내부 실험이다. ADR-0018은 내부 ReadOnly profile, Artifact-bearing Core Receipt v2와 기존 구성요소를 조합하는 bounded CLI library driver를 추가했다. ADR-0019는 event-anchored `ToolOutputRecord`와 SQLite physical schema 7을 추가했고, ADR-0020·0021은 generation-checked planning context와 durable completion을 schema 8에 연결했다. Registry와 Router는 기존 Definition/Instance를 사용한다. `xgen-policy`의 Allow는 provisional이며 runtime Admission이 exact invocation의 one-shot authority를 Run/Step/action/Instance/material에 결합한다. `xgen-adapter-reference`는 비제품 conformance 기준이다. 제품 `xgen-adapter-filesystem`은 exact-file `read-text`와 opt-in directory `list-directory`·`stat`·`search-text`·`read-text-lines`·`write-atomic`·`apply-patch`를 제공한다. `xgen-adapter-process`는 host-catalogued executable을 shell 없이 bounded 실행한다. Public CLI는 기존 `--allow-file` profile을 보존하면서 `--allow-dir` discovery, 별도 `--allow-write` atomic mutation과 별도 `--allow-execute` process 승인을 제공한다. Bare `xgen` REPL은 이 composition을 재사용해 interactive 승인, durable progress와 재개를 제공한다. Network adapter, MCP, Connector와 XGEN 원격 연동은 아직 구현하지 않는다.
## 준비물
diff --git a/docs/development/workspace-filesystem-discovery.md b/docs/development/workspace-filesystem-discovery.md
index bfc5d3a..c2daa7f 100644
--- a/docs/development/workspace-filesystem-discovery.md
+++ b/docs/development/workspace-filesystem-discovery.md
@@ -28,7 +28,7 @@ xgen run \
'프로젝트 구조를 확인하고 관련 구현을 찾아 설명해줘.'
```
-`--allow-dir .`은 선택한 workspace root 전체를 네 read-only Capability로 관찰할 후보 경계를 제공한다.
+`--allow-dir .`은 선택한 workspace root 전체를 다섯 read-only Capability로 관찰할 후보 경계를 제공한다.
같은 mode에서 `write-atomic`과 `apply-patch`도 광고되지만 실제 mutation은 별도 `--allow-write`가 있어야 한다.
Planner는 immutable input schema에서 `.` 규칙을 받고, 별도 `planningConstraints`에서 caller가 허용한
directory/file 목록을 받는다. Constrained provider prompt는 이를 후보 제한으로 따르되 권한으로
@@ -42,6 +42,7 @@ directory/file 목록을 받는다. Constrained provider prompt는 이를 후보
| `xgeny.fs/stat@1.0.0` | regular file size 또는 directory kind |
| `xgeny.fs/search-text@1.0.0` | directory 아래 case-sensitive literal UTF-8 검색 |
| `xgeny.fs/read-text@1.0.0` | bounded UTF-8 file content 읽기 |
+| `xgeny.fs/read-text-lines@1.0.0` | 8 MiB 이하 UTF-8 file의 줄 범위 읽기 ([ADR-0053](../adr/0053-bounded-line-range-text-read.md)) |
| `xgeny.fs/write-atomic@1.0.0` | 별도 승인 아래 UTF-8 file create/atomic replace |
| `xgeny.fs/apply-patch@1.0.0` | 별도 승인 아래 기존 UTF-8 file의 strict contextual edit |
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 228dc72..348a3d2 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -327,7 +327,7 @@ xgen run \
```
이 mode는 `list-directory`, `stat`, case-sensitive literal `search-text`, bounded UTF-8
-`read-text`, `write-atomic`, `apply-patch`를 제공한다. `--allow-write`는 read/model egress와 독립된 동의다. 더 좁게
+`read-text`, 큰 파일용 줄 범위 `read-text-lines`, `write-atomic`, `apply-patch`를 제공한다. `--allow-write`는 read/model egress와 독립된 동의다. 더 좁게
허용하려면 `--allow-dir src --allow-dir tests`처럼 반복하고,
directory 밖의 exact file만 `--allow-file Cargo.toml`로 추가한다. 미완료 재개에는 원래와 동일한
`--allow-dir`/`--allow-file` 집합을 다시 제공해야 한다.
diff --git a/protocol/fixtures/v1alpha1/manifest.json b/protocol/fixtures/v1alpha1/manifest.json
index 8ff750f..0dc3054 100644
--- a/protocol/fixtures/v1alpha1/manifest.json
+++ b/protocol/fixtures/v1alpha1/manifest.json
@@ -5,6 +5,7 @@
{"file": "valid/capability-definition.fs-list-directory.json", "schema": "capability-definition.schema.json", "expectedValid": true},
{"file": "valid/capability-definition.fs-stat.json", "schema": "capability-definition.schema.json", "expectedValid": true},
{"file": "valid/capability-definition.fs-search-text.json", "schema": "capability-definition.schema.json", "expectedValid": true},
+ {"file": "valid/capability-definition.fs-read-text-lines.json", "schema": "capability-definition.schema.json", "expectedValid": true},
{"file": "valid/capability-definition.fs-apply-patch.json", "schema": "capability-definition.schema.json", "expectedValid": true},
{"file": "valid/capability-definition.fs-write-atomic.json", "schema": "capability-definition.schema.json", "expectedValid": true},
{"file": "valid/capability-definition.non-idempotent-durable-output.json", "schema": "capability-definition.schema.json", "expectedValid": true},
diff --git a/protocol/fixtures/v1alpha1/valid/capability-definition.fs-read-text-lines.json b/protocol/fixtures/v1alpha1/valid/capability-definition.fs-read-text-lines.json
new file mode 100644
index 0000000..cac6aae
--- /dev/null
+++ b/protocol/fixtures/v1alpha1/valid/capability-definition.fs-read-text-lines.json
@@ -0,0 +1,76 @@
+{
+ "apiVersion": "xgeny.io/v1alpha1",
+ "kind": "CapabilityDefinition",
+ "metadata": {
+ "id": "xgeny.fs/read-text-lines",
+ "contractVersion": "1.0.0",
+ "displayName": "Read text file lines",
+ "labels": {
+ "xgeny.io/pack": "core-os"
+ }
+ },
+ "spec": {
+ "summary": "Read a range of lines from a UTF-8 text file of up to 8 MiB without following symbolic links. Use it for files larger than read-text accepts (64 KiB) or to read around a search-text match line. Each returned line keeps its line terminator; continue from endLine + 1 while endLine is below totalLines.",
+ "inputSchema": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "required": ["path", "startLine", "lineCount"],
+ "properties": {
+ "path": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Slash-separated workspace-relative file path."
+ },
+ "startLine": {
+ "type": "integer",
+ "minimum": 1,
+ "description": "One-based first line to return."
+ },
+ "lineCount": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 2000,
+ "description": "Maximum number of lines to return."
+ }
+ },
+ "additionalProperties": false
+ },
+ "outputSchema": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "required": ["content", "startLine", "endLine", "totalLines", "truncated", "digest"],
+ "properties": {
+ "content": {"type": "string", "description": "The selected lines, at most 64 KiB once JSON-escaped."},
+ "startLine": {"type": "integer", "minimum": 1},
+ "endLine": {"type": "integer", "minimum": 0, "description": "Last returned line; startLine - 1 when no line is returned."},
+ "totalLines": {"type": "integer", "minimum": 0},
+ "truncated": {"type": "boolean", "description": "True when the content limit stopped the range early or cut its only line."},
+ "digest": {"type": "string", "pattern": "^sha256:[a-f0-9]{64}$"}
+ },
+ "additionalProperties": false
+ },
+ "effect": {
+ "class": "read_only",
+ "resourceSelectors": [
+ {"scope": "filesystem.read", "argumentPointer": "/path"}
+ ],
+ "criticalActions": []
+ },
+ "requiredCapabilities": [],
+ "execution": {
+ "styles": ["sync"],
+ "cancellable": false,
+ "idempotencyKeySupported": false,
+ "defaultTimeoutMs": 30000,
+ "maxTimeoutMs": 30000
+ },
+ "verification": [
+ {"strategy": "output_schema", "required": true},
+ {"strategy": "postcondition", "required": true, "description": "Digest matches the canonical bounded line-range observation."}
+ ],
+ "discovery": {
+ "keywords": ["file", "large", "lines", "range", "read", "text"],
+ "examples": ["Read lines 1200 to 1400 of src/server.py."]
+ }
+ }
+}