Retry incomplete agent repairs safely

This commit is contained in:
2026-08-27 19:12:59 +02:00
parent 4895a2a91b
commit d9fe2747a1
6 changed files with 89 additions and 2 deletions

View File

@@ -86,5 +86,5 @@ class AiderAgent:
"--edit-format", "--edit-format",
"diff", "diff",
"--timeout", "--timeout",
"60", "180",
] ]

View File

@@ -33,6 +33,15 @@ def build_task(report: FailureReport) -> str:
response = redact(report.response) response = redact(report.response)
error = redact(report.error_message) error = redact(report.error_message)
sql = redact(report.sql_query) sql = redact(report.sql_query)
tenant_scope = ''
if report.context.get('database_type') == 'customer':
tenant_scope = """
Tenant routing fact:
The customer has already selected one tenant database. SQL inside that database must not
invent an additional customer discriminator column. Use only columns present in tracked
schema/semantics, and fix SQL-generation guidance rather than rewriting one unknown column
to another guessed column.
"""
return f"""You are fixing failure report #{report.id} in the Laravel AI Chat repository. return f"""You are fixing failure report #{report.id} in the Laravel AI Chat repository.
@@ -68,6 +77,7 @@ Observed error:
Generated SQL, if relevant: Generated SQL, if relevant:
{sql or '[not available]'} {sql or '[not available]'}
{tenant_scope}
Run focused tests while working. Leave the repository with the fix and regression test applied. Run focused tests while working. Leave the repository with the fix and regression test applied.
""" """

View File

@@ -50,6 +50,18 @@ class AutomationWorker:
metadata["changes"] = asdict(summary) metadata["changes"] = asdict(summary)
policy = evaluate_changes(summary, self.settings, workspace.path) policy = evaluate_changes(summary, self.settings, workspace.path)
if not policy.allowed: if not policy.allowed:
if not summary.files and attempt < self.settings.max_agent_attempts:
LOG.warning(
"Agent produced no changes for report %s on attempt %s; requesting correction",
report.id,
attempt,
)
task = build_retry_task(
RuntimeError(
"No file changes were applied. Make the smallest concrete code and regression-test edits now."
)
)
continue
raise RuntimeError( raise RuntimeError(
"Policy rejected changes: " + "; ".join(policy.errors) "Policy rejected changes: " + "; ".join(policy.errors)
) )

View File

@@ -12,7 +12,7 @@ def test_aider_command_cannot_mutate_git_configuration_or_send_analytics() -> No
assert "--no-analytics" in command assert "--no-analytics" in command
assert "--no-auto-commits" in command assert "--no-auto-commits" in command
assert command.count("/dev/null") == 2 assert command.count("/dev/null") == 2
assert command[-4:] == ["--edit-format", "diff", "--timeout", "60"] assert command[-4:] == ["--edit-format", "diff", "--timeout", "180"]
def test_aider_environment_does_not_include_database_credentials(tmp_path, monkeypatch) -> None: def test_aider_environment_does_not_include_database_credentials(tmp_path, monkeypatch) -> None:

View File

@@ -59,3 +59,26 @@ def test_retry_task_contains_failure_without_secrets() -> None:
assert "Missing import" in task assert "Missing import" in task
assert "secret-value" not in task assert "secret-value" not in task
assert "Do not remove or weaken tests" in task assert "Do not remove or weaken tests" in task
def test_customer_database_task_explains_tenant_scope() -> None:
report = FailureReport(
id=13,
chat_message_id=1,
source="automatic",
category="sql_execution",
user_feedback=None,
feedback_type=None,
expected_behavior=None,
prompt="What did this customer buy most?",
response=None,
sql_query="SELECT * FROM invoice_row WHERE InvoiceCreator = 'tenant'",
error_message="Unknown column InvoiceCreator",
context={"database_type": "customer"},
)
task = build_task(report)
assert "already selected one tenant database" in task
assert "must not" in task
assert "guessed column" in task

View File

@@ -97,3 +97,45 @@ def test_failed_validation_gets_one_correction_before_pr() -> None:
assert worker._run_tests.call_count == 2 assert worker._run_tests.call_count == 2
workspace.push_branch.assert_called_once() workspace.push_branch.assert_called_once()
worker.reports.complete.assert_called_once() worker.reports.complete.assert_called_once()
def test_no_change_response_gets_one_correction() -> None:
report = FailureReport(
id=14,
chat_message_id=1,
source="automatic",
category="sql_execution",
user_feedback=None,
feedback_type=None,
expected_behavior=None,
prompt="List products",
response=None,
sql_query=None,
error_message="Unknown column",
context={},
)
config = settings(mode="pr", dry_run=False, gitea_token="token")
worker = AutomationWorker(config)
worker.reports = Mock()
worker.reports.claim_next.return_value = report
worker.agent = Mock()
worker.agent.run.side_effect = ["no edit", "applied edit"]
worker.gitea = Mock()
worker._run_tests = Mock(return_value=[])
workspace = Mock()
workspace.path = Path("/tmp/nonexistent-workspace")
workspace.branch = "ai/failure-14"
workspace.summary.side_effect = [
ChangeSummary((), 0, 0),
ChangeSummary(("tests/Feature/FixTest.php",), 5, 0),
ChangeSummary(("tests/Feature/FixTest.php",), 5, 0),
]
workspace.commit.return_value = "commit-id"
with patch("lai_automation.worker.GitWorkspace", return_value=workspace):
assert worker.run_once()
assert worker.agent.run.call_count == 2
assert "No file changes" in worker.agent.run.call_args_list[1].args[1]
worker._run_tests.assert_called_once()
workspace.push_branch.assert_called_once()