Skip to content

Commit 09660e0

Browse files
committed
feat(ReadFiles): add structured sections and line-range to ReadFiles executor
- Add extract_markdown_sections() for nested section tree extraction - Add generate_file_outline_with_sections() returning tree + depth metadata - Add sections/max_depth/total_sections to ReadFilesOutput - Add line_start/line_end to ReadFilesInput for precise content slicing - new unit tests covering sections, line-range, and fallback behavior
1 parent 28ba03e commit 09660e0

5 files changed

Lines changed: 659 additions & 5 deletions

File tree

README.md

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -323,9 +323,11 @@ Read files with glob patterns, multiple output modes, and automatic outline extr
323323
**Features:**
324324
- Glob pattern support (`*.py`, `**/*.ts`)
325325
- Three output modes:
326-
- `full` - Complete file content
327-
- `outline` - Structural outline (90-97% context reduction for Python/Markdown)
326+
- `full` - Complete file content (with optional line-range slicing)
327+
- `outline` - Structural outline + structured sections tree (90-97% context reduction)
328328
- `summary` - Outline + docstrings/comments
329+
- **Structured sections tree** — Outline mode returns a nested `sections` tree with line ranges, suitable for recursive section-by-section processing
330+
- **Line-range reading** — `line_start`/`line_end` parameters in full mode to read specific portions of a file
329331
- Gitignore integration and file filtering
330332
- Size limits and file count limits
331333
- Multi-file reading in single block
@@ -334,6 +336,41 @@ Read files with glob patterns, multiple output modes, and automatic outline extr
334336
- Multiple files: YAML-formatted structure
335337
- No files: Empty string
336338

339+
**Outline mode outputs:**
340+
341+
| Output | Description |
342+
|--------|-------------|
343+
| `content` | Display outline string |
344+
| `sections` | Nested section tree (list of dicts with `id`, `heading`, `path`, `level`, `line_start`, `line_end`, `own_start`, `own_end`, `is_leaf`, `children`) |
345+
| `max_depth` | Maximum heading depth in document structure |
346+
| `total_sections` | Total number of sections across all levels |
347+
348+
**Example — Structured sections:**
349+
```yaml
350+
- id: read_outline
351+
type: ReadFiles
352+
inputs:
353+
path: "/path/to/document.md"
354+
mode: outline
355+
356+
- id: show_structure
357+
type: Shell
358+
depends_on: [read_outline]
359+
inputs:
360+
command: echo "Found {{blocks.read_outline.outputs.total_sections}} sections"
361+
```
362+
363+
**Example — Line-range reading:**
364+
```yaml
365+
- id: read_section
366+
type: ReadFiles
367+
inputs:
368+
path: "/path/to/document.md"
369+
mode: full
370+
line_start: 10
371+
line_end: 25
372+
```
373+
337374
**See examples:** `tests/workflows/core/file-operations/readfiles-test.yaml`
338375

339376
### ✏️ Deterministic File Editing

schema.json

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -637,6 +637,38 @@
637637
"description": "Text encoding for reading files",
638638
"title": "Encoding",
639639
"type": "string"
640+
},
641+
"line_start": {
642+
"anyOf": [
643+
{
644+
"type": "integer"
645+
},
646+
{
647+
"type": "string"
648+
},
649+
{
650+
"type": "null"
651+
}
652+
],
653+
"default": null,
654+
"description": "Start line for line-range reading (1-indexed, inclusive). Only used with 'path' in 'full' mode. Supports interpolation.",
655+
"title": "Line Start"
656+
},
657+
"line_end": {
658+
"anyOf": [
659+
{
660+
"type": "integer"
661+
},
662+
{
663+
"type": "string"
664+
},
665+
{
666+
"type": "null"
667+
}
668+
],
669+
"default": null,
670+
"description": "End line for line-range reading (1-indexed, inclusive). Only used with 'path' in 'full' mode. Supports interpolation.",
671+
"title": "Line End"
640672
}
641673
},
642674
"title": "ReadFilesInput",
@@ -2707,6 +2739,38 @@
27072739
"description": "Text encoding for reading files",
27082740
"title": "Encoding",
27092741
"type": "string"
2742+
},
2743+
"line_start": {
2744+
"anyOf": [
2745+
{
2746+
"type": "integer"
2747+
},
2748+
{
2749+
"type": "string"
2750+
},
2751+
{
2752+
"type": "null"
2753+
}
2754+
],
2755+
"default": null,
2756+
"description": "Start line for line-range reading (1-indexed, inclusive). Only used with 'path' in 'full' mode. Supports interpolation.",
2757+
"title": "Line Start"
2758+
},
2759+
"line_end": {
2760+
"anyOf": [
2761+
{
2762+
"type": "integer"
2763+
},
2764+
{
2765+
"type": "string"
2766+
},
2767+
{
2768+
"type": "null"
2769+
}
2770+
],
2771+
"default": null,
2772+
"description": "End line for line-range reading (1-indexed, inclusive). Only used with 'path' in 'full' mode. Supports interpolation.",
2773+
"title": "Line End"
27102774
}
27112775
},
27122776
"title": "ReadFilesInput",

src/workflows_mcp/engine/executors_file.py

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,22 @@ def _default_base_path(cls, v: Any) -> Any:
298298

299299
encoding: str = Field(default="utf-8", description="Text encoding for reading files")
300300

301+
line_start: int | str | None = Field(
302+
default=None,
303+
description=(
304+
"Start line for line-range reading (1-indexed, inclusive). "
305+
"Only used with 'path' in 'full' mode. Supports interpolation."
306+
),
307+
)
308+
309+
line_end: int | str | None = Field(
310+
default=None,
311+
description=(
312+
"End line for line-range reading (1-indexed, inclusive). "
313+
"Only used with 'path' in 'full' mode. Supports interpolation."
314+
),
315+
)
316+
301317
# Validators for interpolatable fields
302318
_validate_mode = field_validator("mode", mode="before")(
303319
interpolatable_literal_validator("full", "outline", "summary")
@@ -315,6 +331,14 @@ def _default_base_path(cls, v: Any) -> Any:
315331
interpolatable_boolean_validator()
316332
)
317333

334+
_validate_line_start = field_validator("line_start", mode="before")(
335+
interpolatable_numeric_validator(int, ge=1)
336+
)
337+
338+
_validate_line_end = field_validator("line_end", mode="before")(
339+
interpolatable_numeric_validator(int, ge=1)
340+
)
341+
318342
@model_validator(mode="after")
319343
def validate_path_or_patterns(self) -> ReadFilesInput:
320344
"""Ensure either path OR patterns is provided, not both or neither."""
@@ -363,6 +387,21 @@ class ReadFilesOutput(BlockOutput):
363387
description="Total number of files matching patterns before filtering",
364388
)
365389

390+
sections: list[dict] = Field(
391+
default_factory=list,
392+
description="Structured section tree from outline mode (nested dicts with children)",
393+
)
394+
395+
max_depth: int = Field(
396+
default=0,
397+
description="Maximum heading depth in document structure",
398+
)
399+
400+
total_sections: int = Field(
401+
default=0,
402+
description="Total number of sections across all levels",
403+
)
404+
366405
@computed_field # type: ignore[prop-decorator]
367406
@property
368407
def content(self) -> str:
@@ -482,7 +521,20 @@ async def execute( # type: ignore[override]
482521

483522
# 2. Handle single-file path mode (fast path)
484523
if inputs.path is not None:
485-
return await self._execute_single_file(inputs.path, mode, max_file_size_kb)
524+
# Resolve optional line-range params
525+
resolved_line_start = (
526+
resolve_interpolatable_numeric(inputs.line_start, int, "line_start", ge=1)
527+
if inputs.line_start is not None
528+
else None
529+
)
530+
resolved_line_end = (
531+
resolve_interpolatable_numeric(inputs.line_end, int, "line_end", ge=1)
532+
if inputs.line_end is not None
533+
else None
534+
)
535+
return await self._execute_single_file(
536+
inputs.path, mode, max_file_size_kb, resolved_line_start, resolved_line_end
537+
)
486538

487539
# 3. Handle multi-file patterns mode
488540
max_files = resolve_interpolatable_numeric(inputs.max_files, int, "max_files", ge=1, le=100)
@@ -642,15 +694,24 @@ async def _execute_single_file(
642694
file_path_str: str,
643695
mode: Literal["full", "outline", "summary"],
644696
max_file_size_kb: int,
697+
line_start: int | None = None,
698+
line_end: int | None = None,
645699
) -> ReadFilesOutput:
646700
"""Fast path for reading a single file directly by path.
647701
648702
Skips glob matching, gitignore checks, and exclusion filters.
649703
Used when `path` parameter is provided instead of `patterns`.
704+
705+
Args:
706+
file_path_str: Path string to the file
707+
mode: Read mode (full, outline, summary)
708+
max_file_size_kb: Max file size limit
709+
line_start: Optional 1-indexed start line for line-range reading (full mode only)
710+
line_end: Optional 1-indexed end line for line-range reading (full mode only)
650711
"""
651712
from .file_outline import (
652713
BASE64_ENCODE_EXTENSIONS,
653-
generate_file_outline,
714+
generate_file_outline_with_sections,
654715
is_binary,
655716
)
656717

@@ -688,9 +749,14 @@ async def _execute_single_file(
688749
# Read file content based on mode
689750
file_ext = file_path.suffix.lower()
690751
file_content: str
752+
sections: list[dict] = []
753+
max_depth = 0
754+
total_sections = 0
691755

692756
if mode == "outline" or mode == "summary":
693-
file_content = generate_file_outline(file_path, mode)
757+
file_content, sections, max_depth, total_sections = generate_file_outline_with_sections(
758+
file_path, mode
759+
)
694760

695761
elif file_ext in BASE64_ENCODE_EXTENSIONS:
696762
import base64
@@ -722,6 +788,19 @@ async def _execute_single_file(
722788
assert read_result.value is not None
723789
file_content = read_result.value
724790

791+
# Apply line-range slicing if requested (full mode only)
792+
if line_start is not None or line_end is not None:
793+
lines = file_content.split("\n")
794+
total_lines = len(lines)
795+
start = (line_start or 1) - 1 # Convert to 0-indexed
796+
end = line_end or total_lines
797+
798+
if start >= total_lines:
799+
file_content = ""
800+
else:
801+
end = min(end, total_lines)
802+
file_content = "\n".join(lines[start:end])
803+
725804
return ReadFilesOutput(
726805
files=[
727806
FileInfo(
@@ -734,6 +813,9 @@ async def _execute_single_file(
734813
total_size_kb=file_size_kb,
735814
skipped_files=[],
736815
patterns_matched=1,
816+
sections=sections,
817+
max_depth=max_depth,
818+
total_sections=total_sections,
737819
)
738820

739821

0 commit comments

Comments
 (0)