Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions src/foundation/services/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,47 @@ def _unwrap_generated_file_body(text: str) -> str:
return text


# Read-only typed results whose payload should be surfaced to the planner so it
# can actually use what the tool returned (not just see that it ran). Writes and
# mutations are excluded — echoing their content back only bloats the prompt.
_RESULT_PREVIEW_TYPES = frozenset(
{
ExecutionArtifactType.FILE_READ,
ExecutionArtifactType.FILE_READ_CHUNK,
ExecutionArtifactType.SEARCH,
ExecutionArtifactType.FILES,
ExecutionArtifactType.GIT,
ExecutionArtifactType.GIT_STATUS,
ExecutionArtifactType.GIT_DIFF,
ExecutionArtifactType.GIT_SHOW,
ExecutionArtifactType.GIT_LOG,
ExecutionArtifactType.MAN,
ExecutionArtifactType.TLDR,
}
)


def _tool_result_preview(
artifact: dict[str, object] | None,
artifact_type: ExecutionArtifactType | None,
) -> str:
"""Render a read-only tool result's payload for the planner observation.

Typed capabilities (file reads, search, discovery, git inspect) don't use
the shell ``stdout`` field, so without this their output never reaches the
planner and it can't act on what it just fetched.
"""
if not artifact or artifact_type not in _RESULT_PREVIEW_TYPES:
return ""
content = artifact.get("content")
if isinstance(content, str) and content:
return content
try:
return json.dumps(artifact, default=str)
except (TypeError, ValueError):
return ""


def _action_target_path(action: PlannedAction) -> str | None:
if action.kind is not ActionKind.TOOL_CALL or action.tool_call is None:
return None
Expand Down Expand Up @@ -1280,6 +1321,13 @@ def _build_observation(
stdout_preview = _truncate_preview(
f'User answered: "{result.artifact["answer"]}"'
)
# Surface typed read-only results (file reads, search, git
# inspect) so the planner sees the data it fetched instead of an
# empty outcome — without this it re-runs the same read forever.
if not stdout_preview:
preview = _tool_result_preview(result.artifact, result.artifact_type)
if preview:
stdout_preview = _truncate_preview(preview)

if result.status is ExecutionStatus.PENDING_APPROVAL:
approval_outcomes.append(f"{action.id}: pending approval")
Expand Down
56 changes: 56 additions & 0 deletions tests/test_orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -3007,3 +3007,59 @@ def test_orchestrator_not_found_surfaces_siblings_for_self_correction(
and r.artifact_type is ExecutionArtifactType.FILE_READ
for r in result.execution_results
)


def test_tool_result_preview_surfaces_reads_not_writes() -> None:
from foundation.services.orchestrator import _tool_result_preview

# File-read content is surfaced verbatim.
assert (
_tool_result_preview(
{"path": "a.md", "content": "# Hi\nbody"}, ExecutionArtifactType.FILE_READ
)
== "# Hi\nbody"
)
# Structured read-only results (search) are surfaced as compact JSON.
search_preview = _tool_result_preview(
{"matches": ["a.py:1: needle"]}, ExecutionArtifactType.SEARCH
)
assert "a.py:1: needle" in search_preview
# Writes are NOT echoed back (would only re-bloat the prompt).
assert (
_tool_result_preview(
{"path": "a.md", "content": "huge body"}, ExecutionArtifactType.FILE_WRITE
)
== ""
)
assert _tool_result_preview(None, ExecutionArtifactType.FILE_READ) == ""


def test_orchestrator_surfaces_file_read_content_to_next_iteration(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
provider = StubProvider(
[
_provider_response(
{
"assistant_message": "Reading the note.",
"actions": [_read_action("read_note", "note.txt")],
}
)
# iteration 2: StubProvider returns a zero-action completion by default.
]
)
orchestrator, _, workspace_root = _orchestrator(tmp_path, monkeypatch, provider)
(workspace_root / "note.txt").write_text("SECRET-CONTENT-12345\n", encoding="utf-8")

result = orchestrator.orchestrate(UserRequest(message="read note.txt"))

# The read succeeded AND its content reached the planner's next iteration —
# previously the observation was blank and the model re-read forever.
assert any(
r.status is ExecutionStatus.EXECUTED
and r.artifact_type is ExecutionArtifactType.FILE_READ
for r in result.execution_results
)
second_plan_text = "\n".join(m.content for m in provider.calls[1].messages)
assert "SECRET-CONTENT-12345" in second_plan_text
Loading