Skip to content
Merged
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
63 changes: 62 additions & 1 deletion apps/backend/agents/runtime/adapters/generic_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -1608,6 +1608,64 @@ async def _execute_native_tool_actions(
action_results=[],
executed_actions=[],
)
answered_tool_call_ids: set[str] = set()
try:
return await self._run_native_tool_call_loop(
tool_actions=tool_actions,
execution=execution,
answered_tool_call_ids=answered_tool_call_ids,
iteration_entry=iteration_entry,
observation_path=observation_path,
iteration=iteration,
spec_dir=spec_dir,
verbose=verbose,
phase=phase,
subtask_id=subtask_id,
trace=trace,
)
finally:
# Tool-call protocol invariant: EVERY tool_call in the assistant
# message must receive a tool response before the next
# completion. Early exits (an action failure stops the batch,
# cancellation, or an exception) used to leave the remaining
# calls unanswered, poisoning the session history — the next
# completion then failed with provider 400 "tool_call_ids did
# not have response messages" (live-build finding, 2026-06-12).
for tool_call, action in tool_actions:
call_id = str(getattr(tool_call, "id", "") or "")
if not call_id or call_id in answered_tool_call_ids:
continue
self.agent_session.add_tool_result(
call_id,
action_tool(action),
ToolActionResult(
tool=action_tool(action),
ok=False,
message=(
"not executed: an earlier tool call in this "
"batch failed or the run was interrupted"
),
data={"error_code": "tool_call_not_executed"},
).to_dict(),
)
answered_tool_call_ids.add(call_id)

async def _run_native_tool_call_loop(
self,
*,
tool_actions: list[tuple[Any, dict[str, Any]]],
execution: NativeToolExecutionResult,
answered_tool_call_ids: set[str],
iteration_entry: dict[str, Any],
observation_path: Path,
iteration: int,
spec_dir: Path,
verbose: bool,
phase: Any,
subtask_id: str | None,
trace: list[dict[str, Any]],
) -> NativeToolExecutionResult:
"""Run the native tool-call loop; the caller backfills unanswered ids."""
for action_index, (tool_call, action) in enumerate(tool_actions, start=1):
if self._cancel_requested:
execution.cancelled = True
Expand Down Expand Up @@ -1653,11 +1711,14 @@ async def _execute_native_tool_actions(
request=safe_request,
result=result,
)
tool_call_id = str(getattr(tool_call, "id", "") or "")
self.agent_session.add_tool_result(
str(getattr(tool_call, "id", "") or ""),
tool_call_id,
action_tool(action),
result.to_dict(),
)
if tool_call_id:
answered_tool_call_ids.add(tool_call_id)
if action_tool(action) == "finish":
execution.finish_action = action
execution.finish_result = result
Expand Down
53 changes: 53 additions & 0 deletions tests/test_agent_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -12325,6 +12325,59 @@ def test_generic_edit_mutation_snapshot_artifact_rejects_count_mismatch(
load_generic_edit_mutation_snapshots(snapshot_path)


@pytest.mark.asyncio
async def test_generic_edit_native_tools_backfill_unanswered_batch_calls(
tmp_path: Path,
):
"""Every tool_call in a batch gets a tool response even when an early
call fails and stops the batch — otherwise the next completion sends
assistant tool_calls without responses and the provider rejects the
whole session with 400 (live-build finding, 2026-06-12)."""
session = FakeNativeToolCallSession(
[
[
# Fails (missing file) and stops the batch...
{"name": "read_file", "arguments": {"path": "missing.txt"}},
# ...leaving this one unexecuted: it must still be answered.
{
"name": "write_file",
"arguments": {"path": "later.txt", "content": "x\n"},
},
],
[{"name": "finish", "arguments": {"summary": "wrap up"}}],
]
)
runtime_session = create_runtime_session(
provider_name="openai",
agent_session=session,
runtime_mode="generic_edit",
project_dir=tmp_path,
)

await run_runtime_session(
runtime_session,
"exercise unanswered tool-call backfill",
tmp_path,
requirements=RuntimeRequirements.generic_edit(),
)

first_batch = [
entry
for entry in session.tool_results
if entry["tool_call_id"].startswith("call_1_")
]
assert [entry["tool_call_id"] for entry in first_batch] == [
"call_1_1",
"call_1_2",
]
assert first_batch[0]["result"]["ok"] is False
backfilled = first_batch[1]["result"]
assert backfilled["ok"] is False
assert backfilled["data"]["error_code"] == "tool_call_not_executed"
# later.txt was never executed — only answered.
assert not (tmp_path / "later.txt").exists()


@pytest.mark.asyncio
async def test_generic_edit_native_tools_reject_finish_with_unresolved_partial_failure(
tmp_path: Path,
Expand Down
Loading