Skip to content

Commit a149c80

Browse files
authored
Merge pull request #1245 from unoplat/fix-read-tool-call-truncation-issue
fix(read-tool-call-pagination-issue): align updater read clamp and no…
2 parents 4d83eb6 + df93429 commit a149c80

5 files changed

Lines changed: 262 additions & 11 deletions

File tree

‎unoplat-code-confluence-query-engine/src/unoplat_code_confluence_query_engine/services/temporal/temporal_agents.py‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,12 @@ def build_section_updater_prompt(
177177
f"Codebase path: {codebase_path}\n"
178178
f"Programming language metadata: {programming_language_metadata}\n"
179179
f"Section data:\n{section_data}\n\n"
180+
"Deterministic update workflow:\n"
181+
"- Read the target section first (and only within scope).\n"
182+
"- Compute expected section content from section_data.\n"
183+
"- Compare current vs expected content before writing.\n"
184+
"- If equivalent, do NOT call write tools and return status='no_changes'.\n"
185+
"- Use updater_apply_patch/updater_edit_file only when a real diff exists.\n\n"
180186
"Return summary metadata only (no raw file content)."
181187
)
182188

@@ -317,6 +323,36 @@ def validate_agents_md_updater_output(
317323
raise ModelRetry("touched_file_paths cannot be empty")
318324
if not output.file_changes:
319325
raise ModelRetry("file_changes cannot be empty")
326+
327+
if output.status == "no_changes":
328+
for file_change in output.file_changes:
329+
if file_change.changed:
330+
raise ModelRetry(
331+
"status no_changes requires file_changes[].changed to be false"
332+
)
333+
if file_change.change_type != "unchanged":
334+
raise ModelRetry(
335+
"status no_changes requires file_changes[].change_type to be 'unchanged'"
336+
)
337+
338+
if output.status == "updated":
339+
has_changed_file = any(
340+
file_change.changed for file_change in output.file_changes
341+
)
342+
if not has_changed_file:
343+
raise ModelRetry(
344+
"status updated requires at least one file_changes[].changed to be true"
345+
)
346+
347+
has_mutation_change_type = any(
348+
file_change.change_type in {"created", "updated"}
349+
for file_change in output.file_changes
350+
)
351+
if not has_mutation_change_type:
352+
raise ModelRetry(
353+
"status updated requires at least one file_changes[].change_type in {'created','updated'}"
354+
)
355+
320356
return output
321357

322358

@@ -509,6 +545,9 @@ def create_agents_md_updater_agent(
509545
- Always pass absolute file paths.
510546
- Operate only within the current codebase root.
511547
- Prefer updater_apply_patch for creates and structured updates.
548+
- Read the target section first, then compute the expected section body from section_data.
549+
- Compare before write: if content is already equivalent, do not call write tools and return status="no_changes".
550+
- Call updater_apply_patch/updater_edit_file only when an actual diff exists.
512551
513552
Output constraints:
514553
- Return summary metadata only.

‎unoplat-code-confluence-query-engine/src/unoplat_code_confluence_query_engine/tools/agents_md_updater_tools.py‎

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,12 @@ def _normalize_scoped_path(
6666
return normalized
6767

6868

69-
def _validate_read_bounds(offset: int, limit: int) -> None:
69+
def _validate_read_bounds(offset: int, limit: int) -> int:
7070
if offset < 0:
7171
raise ModelRetry("invalid_patch: offset must be >= 0")
7272
if limit < 1:
7373
raise ModelRetry("invalid_patch: limit must be >= 1")
74-
if limit > READ_MAX_LIMIT:
75-
raise ModelRetry(f"invalid_patch: limit must be <= {READ_MAX_LIMIT}")
74+
return min(limit, READ_MAX_LIMIT)
7675

7776

7877
def _format_numbered_line(line_number: int, content: str) -> str:
@@ -229,7 +228,7 @@ async def updater_read_file(
229228
limit: int = READ_MAX_LIMIT,
230229
) -> str:
231230
"""Read file content with absolute path guard and truncation."""
232-
_validate_read_bounds(offset, limit)
231+
effective_limit = _validate_read_bounds(offset, limit)
233232
target = _normalize_scoped_path(ctx, file_path=file_path, allow_missing=False)
234233
if target.is_dir():
235234
raise ModelRetry(f"not_found: path is a directory '{target.as_posix()}'")
@@ -241,7 +240,7 @@ async def updater_read_file(
241240
total_lines += 1
242241
if total_lines <= offset:
243242
continue
244-
if len(output_lines) >= limit:
243+
if len(output_lines) >= effective_limit:
245244
break
246245
output_lines.append(
247246
_format_numbered_line(total_lines, raw_line.rstrip("\r\n"))
@@ -294,15 +293,17 @@ async def updater_edit_file(
294293

295294
replace_count = expected_replacements if expected_replacements is not None else 1
296295
updated_content = existing_content.replace(old_string, new_string, replace_count)
296+
changed = updated_content != existing_content
297297

298-
async with aiofiles.open(target, mode="w", encoding="utf-8") as handle:
299-
await handle.write(updated_content)
298+
if changed:
299+
async with aiofiles.open(target, mode="w", encoding="utf-8") as handle:
300+
await handle.write(updated_content)
300301

301302
return {
302303
"file_path": target.as_posix(),
303-
"changed": updated_content != existing_content,
304+
"changed": changed,
304305
"replacements": replace_count,
305-
"status": "updated",
306+
"status": "updated" if changed else "unchanged",
306307
}
307308

308309

@@ -314,6 +315,7 @@ async def updater_apply_patch(
314315
operations = _parse_patch_operations(patch_text)
315316

316317
planned_writes: dict[Path, str | None] = {}
318+
skipped_noop_files = 0
317319
for operation in operations:
318320
scoped = _normalize_scoped_path(
319321
ctx,
@@ -330,6 +332,16 @@ async def updater_apply_patch(
330332
continue
331333

332334
if operation.operation_type == "add":
335+
if scoped.exists() and scoped.is_dir():
336+
raise ModelRetry(
337+
f"not_found: add target is a directory '{scoped.as_posix()}'"
338+
)
339+
if scoped.exists():
340+
async with aiofiles.open(scoped, mode="r", encoding="utf-8") as handle:
341+
existing_content = await handle.read()
342+
if existing_content == operation.add_content:
343+
skipped_noop_files += 1
344+
continue
333345
planned_writes[scoped] = operation.add_content
334346
continue
335347

@@ -344,6 +356,9 @@ async def updater_apply_patch(
344356
existing_content,
345357
chunks=operation.chunks,
346358
)
359+
if updated_content == existing_content:
360+
skipped_noop_files += 1
361+
continue
347362
planned_writes[scoped] = updated_content
348363
continue
349364

@@ -364,4 +379,5 @@ async def updater_apply_patch(
364379
"status": "applied",
365380
"operations": len(operations),
366381
"changed_files": changed_files,
382+
"skipped_noop_files": skipped_noop_files,
367383
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
"""Unit tests for AGENTS.md updater output validation semantics."""
2+
3+
from __future__ import annotations
4+
5+
from pydantic_ai import ModelRetry
6+
import pytest
7+
8+
from unoplat_code_confluence_query_engine.models.output.agents_md_updater_output import (
9+
AgentsMdUpdaterOutput,
10+
UpdaterFileChange,
11+
)
12+
from unoplat_code_confluence_query_engine.services.temporal.temporal_agents import (
13+
validate_agents_md_updater_output,
14+
)
15+
16+
17+
def test_no_changes_with_changed_file_is_rejected() -> None:
18+
"""status=no_changes cannot include changed=true entries."""
19+
output = AgentsMdUpdaterOutput(
20+
status="no_changes",
21+
touched_file_paths=["/repo/AGENTS.md"],
22+
file_changes=[
23+
UpdaterFileChange(
24+
path="/repo/AGENTS.md",
25+
changed=True,
26+
change_type="updated",
27+
change_summary="unexpected mutation",
28+
)
29+
],
30+
)
31+
32+
with pytest.raises(
33+
ModelRetry,
34+
match=r"status no_changes requires file_changes\[\]\.changed to be false",
35+
):
36+
validate_agents_md_updater_output(output)
37+
38+
39+
def test_updated_with_no_changed_files_is_rejected() -> None:
40+
"""status=updated requires at least one changed file."""
41+
output = AgentsMdUpdaterOutput(
42+
status="updated",
43+
touched_file_paths=["/repo/AGENTS.md"],
44+
file_changes=[
45+
UpdaterFileChange(
46+
path="/repo/AGENTS.md",
47+
changed=False,
48+
change_type="unchanged",
49+
change_summary="already up to date",
50+
)
51+
],
52+
)
53+
54+
with pytest.raises(
55+
ModelRetry,
56+
match=r"status updated requires at least one file_changes\[\]\.changed to be true",
57+
):
58+
validate_agents_md_updater_output(output)
59+
60+
61+
def test_valid_no_changes_payload_passes() -> None:
62+
"""Consistent no_changes payload should pass validation."""
63+
output = AgentsMdUpdaterOutput(
64+
status="no_changes",
65+
touched_file_paths=["/repo/AGENTS.md"],
66+
file_changes=[
67+
UpdaterFileChange(
68+
path="/repo/AGENTS.md",
69+
changed=False,
70+
change_type="unchanged",
71+
change_summary="section already equivalent",
72+
)
73+
],
74+
)
75+
76+
validated = validate_agents_md_updater_output(output)
77+
assert validated is output
78+
79+
80+
def test_valid_updated_payload_passes() -> None:
81+
"""Consistent updated payload should pass validation."""
82+
output = AgentsMdUpdaterOutput(
83+
status="updated",
84+
touched_file_paths=["/repo/AGENTS.md", "/repo/business_logic_references.md"],
85+
file_changes=[
86+
UpdaterFileChange(
87+
path="/repo/AGENTS.md",
88+
changed=True,
89+
change_type="updated",
90+
change_summary="refreshed section content",
91+
),
92+
UpdaterFileChange(
93+
path="/repo/business_logic_references.md",
94+
changed=False,
95+
change_type="unchanged",
96+
change_summary="already current",
97+
),
98+
],
99+
)
100+
101+
validated = validate_agents_md_updater_output(output)
102+
assert validated is output

‎unoplat-code-confluence-query-engine/tests/tools/test_agents_md_updater_tools.py‎

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
AgentDependencies,
2323
)
2424
from unoplat_code_confluence_query_engine.tools.agents_md_updater_tools import (
25+
READ_MAX_LIMIT,
2526
_apply_replacement_chunks,
2627
_parse_patch_operations,
2728
updater_apply_patch,
@@ -487,7 +488,9 @@ class TestRootScopeEnforcement:
487488
"""Regression tests for codebase-root-only file operation enforcement."""
488489

489490
@pytest.mark.asyncio
490-
async def test_relative_path_rejected_with_out_of_scope(self, tmp_path: Path) -> None:
491+
async def test_relative_path_rejected_with_out_of_scope(
492+
self, tmp_path: Path
493+
) -> None:
491494
"""Relative paths must raise ModelRetry with 'out_of_scope' prefix."""
492495
ctx = _make_run_context(str(tmp_path))
493496
patch = _build_patch(
@@ -578,3 +581,94 @@ async def test_valid_path_within_root_succeeds(self, tmp_path: Path) -> None:
578581
result = await updater_apply_patch(ctx, patch)
579582
assert result["status"] == "applied"
580583
assert agents_md.read_text() == "# Title\nnew line\n"
584+
585+
586+
class TestUpdaterReadFileLimits:
587+
"""Validate read bound handling for updater_read_file."""
588+
589+
@pytest.mark.asyncio
590+
async def test_limit_above_max_is_clamped(self, tmp_path: Path) -> None:
591+
"""Oversized limit must clamp to READ_MAX_LIMIT instead of failing."""
592+
target = tmp_path / "AGENTS.md"
593+
target.write_text("\n".join(f"line-{index}" for index in range(1, 2105)) + "\n")
594+
ctx = _make_run_context(str(tmp_path))
595+
596+
result = await updater_read_file(ctx, str(target), offset=0, limit=4000)
597+
numbered_lines = [
598+
line for line in result.splitlines() if len(line) > 7 and line[5:7] == "| "
599+
]
600+
601+
assert len(numbered_lines) == READ_MAX_LIMIT
602+
assert "[truncated]" in result
603+
604+
@pytest.mark.asyncio
605+
async def test_limit_below_one_still_rejected(self, tmp_path: Path) -> None:
606+
"""Limit less than one must still fail with invalid_patch."""
607+
target = tmp_path / "AGENTS.md"
608+
target.write_text("content\n")
609+
ctx = _make_run_context(str(tmp_path))
610+
611+
with pytest.raises(ModelRetry, match="limit must be >= 1"):
612+
await updater_read_file(ctx, str(target), limit=0)
613+
614+
615+
class TestUpdaterApplyPatchNoopSemantics:
616+
"""Ensure no-op updates do not write or count as changed files."""
617+
618+
@pytest.mark.asyncio
619+
async def test_update_noop_does_not_count_as_change(self, tmp_path: Path) -> None:
620+
"""Update patch yielding identical content should be treated as no-op."""
621+
target = tmp_path / "AGENTS.md"
622+
target.write_text("line one\nline two\n")
623+
ctx = _make_run_context(str(tmp_path))
624+
625+
patch = _build_patch(
626+
f"*** Update File: {target}",
627+
"@@",
628+
"-line two",
629+
"+line two",
630+
)
631+
632+
result = await updater_apply_patch(ctx, patch)
633+
634+
assert result["operations"] == 1
635+
assert result["changed_files"] == 0
636+
assert result["skipped_noop_files"] == 1
637+
assert target.read_text() == "line one\nline two\n"
638+
639+
@pytest.mark.asyncio
640+
async def test_mixed_operations_track_requested_vs_actual_changes(
641+
self, tmp_path: Path
642+
) -> None:
643+
"""Operations count should include no-op intents; changed_files should not."""
644+
update_target = tmp_path / "AGENTS.md"
645+
update_target.write_text("alpha\nbeta\n")
646+
647+
noop_target = tmp_path / "README.md"
648+
noop_target.write_text("same\n")
649+
650+
add_target = tmp_path / "business_logic_references.md"
651+
ctx = _make_run_context(str(tmp_path))
652+
653+
patch = _build_patch(
654+
f"*** Update File: {update_target}",
655+
"@@",
656+
"-beta",
657+
"+BETA",
658+
f"*** Update File: {noop_target}",
659+
"@@",
660+
"-same",
661+
"+same",
662+
f"*** Add File: {add_target}",
663+
"+# Business Logic",
664+
"+artifact content",
665+
)
666+
667+
result = await updater_apply_patch(ctx, patch)
668+
669+
assert result["operations"] == 3
670+
assert result["changed_files"] == 2
671+
assert result["skipped_noop_files"] == 1
672+
assert update_target.read_text() == "alpha\nBETA\n"
673+
assert noop_target.read_text() == "same\n"
674+
assert add_target.read_text() == "# Business Logic\nartifact content"

‎unoplat-code-confluence-query-engine/uv.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)