Compare commits

..

5 Commits

Author SHA1 Message Date
dd86d8cd2d Keep repair context across agent retries
Some checks failed
CI / test (push) Has been cancelled
2026-08-27 20:02:47 +02:00
e90db58194 Guide tenant repair tests to owning behavior 2026-08-27 19:20:15 +02:00
d9fe2747a1 Retry incomplete agent repairs safely 2026-08-27 19:12:59 +02:00
4895a2a91b Make agent validation self-correcting 2026-08-27 19:07:40 +02:00
66b6df03a6 Initialize isolated database before agent tests 2026-08-27 18:54:32 +02:00
11 changed files with 281 additions and 16 deletions

View File

@@ -2,7 +2,7 @@ FROM python:3.12-slim
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends \
composer git openssh-client php-cli php-curl php-mbstring php-mysql php-xml php-zip unzip \ composer git nodejs npm openssh-client php-cli php-curl php-mbstring php-mysql php-xml php-zip unzip \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app

View File

@@ -43,10 +43,15 @@ Die nachgelagerten Testprozesse erhalten separat konfigurierte `TEST_DB_*`-Zugan
Dieser Benutzer darf ausschliesslich die isolierten Testschemas bearbeiten. Fuer Laravel Dieser Benutzer darf ausschliesslich die isolierten Testschemas bearbeiten. Fuer Laravel
werden daraus `DB_*`, `ERP_DB_*` und `COCKPIT_CONFIG_DATABASE` gesetzt; Aider selbst sieht werden daraus `DB_*`, `ERP_DB_*` und `COCKPIT_CONFIG_DATABASE` gesetzt; Aider selbst sieht
diese Werte nie. Produktive ERP- oder Kundenzugangsdaten duerfen hier nicht verwendet werden. diese Werte nie. Produktive ERP- oder Kundenzugangsdaten duerfen hier nicht verwendet werden.
Vor jedem Testlauf erstellt `npm run build` die Vite-Assets und initialisiert
`php artisan migrate:fresh --force` das isolierte Laravel-Schema neu. Deshalb muessen alle
`TEST_DB_*`-Schemas dediziert und vollstaendig entbehrlich sein.
Laravel-Tests laufen mit PHP und Composer direkt im isolierten Worker. Der Docker-Socket Laravel-Tests laufen mit PHP und Composer direkt im isolierten Worker. Der Docker-Socket
des Hosts wird nicht eingebunden. Abgebrochene Claims werden nach standardmaessig 90 des Hosts wird nicht eingebunden. Abgebrochene Claims werden nach standardmaessig 90
Minuten wieder freigegeben; `STALE_CLAIM_MINUTES` passt dieses Intervall an. Minuten wieder freigegeben; `STALE_CLAIM_MINUTES` passt dieses Intervall an.
Schlaegt die isolierte Validierung fehl, erhaelt Aider standardmaessig genau einen
Korrekturversuch mit redigierter Testausgabe. `MAX_AGENT_ATTEMPTS` ist auf 1 bis 3 begrenzt.
## Schnellstart ## Schnellstart

View File

@@ -21,8 +21,9 @@ services:
AGENT_MODEL: ${AGENT_MODEL:-mistral/devstral-latest} AGENT_MODEL: ${AGENT_MODEL:-mistral/devstral-latest}
MISTRAL_API_KEY: ${MISTRAL_API_KEY:-} MISTRAL_API_KEY: ${MISTRAL_API_KEY:-}
POLL_SECONDS: ${POLL_SECONDS:-60} POLL_SECONDS: ${POLL_SECONDS:-60}
MAX_AGENT_ATTEMPTS: ${MAX_AGENT_ATTEMPTS:-2}
STALE_CLAIM_MINUTES: ${STALE_CLAIM_MINUTES:-90} STALE_CLAIM_MINUTES: ${STALE_CLAIM_MINUTES:-90}
TEST_COMMANDS: ${TEST_COMMANDS:-composer install --no-interaction --prefer-dist;;php artisan test} TEST_COMMANDS: ${TEST_COMMANDS:-composer install --no-interaction --prefer-dist;;npm ci;;npm run build;;php artisan migrate:fresh --force;;php artisan test}
TEST_DB_HOST: ${TEST_DB_HOST:-host.docker.internal} TEST_DB_HOST: ${TEST_DB_HOST:-host.docker.internal}
TEST_DB_PORT: ${TEST_DB_PORT:-33060} TEST_DB_PORT: ${TEST_DB_PORT:-33060}
TEST_DB_NAME: ${TEST_DB_NAME:-laravel_ai_chat_agent_test} TEST_DB_NAME: ${TEST_DB_NAME:-laravel_ai_chat_agent_test}

View File

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

View File

@@ -43,6 +43,7 @@ class Settings:
poll_seconds: int poll_seconds: int
max_changed_files: int max_changed_files: int
max_changed_lines: int max_changed_lines: int
max_agent_attempts: int
stale_claim_minutes: int stale_claim_minutes: int
dry_run: bool dry_run: bool
@@ -75,7 +76,7 @@ class Settings:
test_commands=_commands( test_commands=_commands(
os.getenv( os.getenv(
"TEST_COMMANDS", "TEST_COMMANDS",
"composer install --no-interaction --prefer-dist;;php artisan test", "composer install --no-interaction --prefer-dist;;npm ci;;npm run build;;php artisan migrate:fresh --force;;php artisan test",
) )
), ),
test_database_host=os.getenv("TEST_DB_HOST", "host.docker.internal"), test_database_host=os.getenv("TEST_DB_HOST", "host.docker.internal"),
@@ -88,6 +89,7 @@ class Settings:
poll_seconds=int(os.getenv("POLL_SECONDS", "60")), poll_seconds=int(os.getenv("POLL_SECONDS", "60")),
max_changed_files=int(os.getenv("MAX_CHANGED_FILES", "12")), max_changed_files=int(os.getenv("MAX_CHANGED_FILES", "12")),
max_changed_lines=int(os.getenv("MAX_CHANGED_LINES", "800")), max_changed_lines=int(os.getenv("MAX_CHANGED_LINES", "800")),
max_agent_attempts=int(os.getenv("MAX_AGENT_ATTEMPTS", "2")),
stale_claim_minutes=int(os.getenv("STALE_CLAIM_MINUTES", "90")), stale_claim_minutes=int(os.getenv("STALE_CLAIM_MINUTES", "90")),
dry_run=mode == "dry-run", dry_run=mode == "dry-run",
) )
@@ -103,3 +105,5 @@ class Settings:
raise ValueError("FAILURE_DB_PASSWORD is required") raise ValueError("FAILURE_DB_PASSWORD is required")
if not self.test_database_password: if not self.test_database_password:
raise ValueError("TEST_DB_PASSWORD is required") raise ValueError("TEST_DB_PASSWORD is required")
if not 1 <= self.max_agent_attempts <= 3:
raise ValueError("MAX_AGENT_ATTEMPTS must be between 1 and 3")

View File

@@ -33,6 +33,32 @@ 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. TenantDatabaseService records the explicit source type in
database.connections.<connection>.tenant_type; use that metadata instead of database-name
patterns or heuristics.
"""
sql_semantics = ''
if report.category in {'incorrect_answer', 'sql_execution'}:
sql_semantics = """
SQL repair guidance:
When the SQL is syntactically valid but uses the wrong business calculation, prefer adding or
correcting tracked domain knowledge in config/*_semantics.php so the schema prompt teaches the
model the right joins, filters, and formula. Do not hardcode one user's question or article ID
in SQLGeneratorService, and do not add post-generation replacement of guessed SQL.
For regression coverage of prompt guidance, expose the relevant protected schema/business-
context builder through a test subclass and assert that its real output contains the required
formula and rules. Do not mock AIService to return the known-bad SQL and then expect prompt
guidance to rewrite that mocked response. Keep database fixture tests only when production code
itself deterministically builds and executes the SQL under test.
"""
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 +94,27 @@ Observed error:
Generated SQL, if relevant: Generated SQL, if relevant:
{sql or '[not available]'} {sql or '[not available]'}
{tenant_scope}
{sql_semantics}
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.
""" """
def build_retry_task(report: FailureReport, exception: Exception) -> str:
failure = redact(str(exception))[-6_000:]
return build_task(report) + f"""
The previous implementation failed isolated validation.
Correct the existing changes using this test output:
{failure}
Keep the regression test meaningful. Do not remove or weaken tests, safety checks,
authentication, authorization, SQL validation, or tenant isolation. If a new test calls an
unrelated specialized code path that correctly returns no result, repair the test to exercise
the owning production behavior; never broaden unrelated production routing merely to make the
test pass. Do not commit or push.
"""

View File

@@ -12,7 +12,7 @@ from .git import GitWorkspace
from .gitea import GiteaClient from .gitea import GiteaClient
from .models import FailureReport from .models import FailureReport
from .policy import evaluate_changes from .policy import evaluate_changes
from .prompt import build_task from .prompt import build_retry_task, build_task
LOG = logging.getLogger(__name__) LOG = logging.getLogger(__name__)
@@ -43,15 +43,42 @@ class AutomationWorker:
try: try:
workspace.prepare() workspace.prepare()
task = build_task(report) task = build_task(report)
metadata["agent_output"] = self.agent.run(workspace.path, task) metadata["agent_output"] = []
for attempt in range(1, self.settings.max_agent_attempts + 1):
metadata["agent_output"].append(self.agent.run(workspace.path, task))
summary = workspace.summary() summary = workspace.summary()
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:
raise RuntimeError("Policy rejected changes: " + "; ".join(policy.errors)) 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(
report,
RuntimeError(
"No file changes were applied. Make the smallest concrete code and regression-test edits now."
)
)
continue
raise RuntimeError(
"Policy rejected changes: " + "; ".join(policy.errors)
)
try:
metadata["tests"] = self._run_tests(workspace) metadata["tests"] = self._run_tests(workspace)
break
except RuntimeError as exception:
if attempt >= self.settings.max_agent_attempts:
raise
LOG.warning(
"Tests failed for report %s on attempt %s; requesting correction",
report.id,
attempt,
)
task = build_retry_task(report, exception)
summary = workspace.summary() summary = workspace.summary()
metadata["changes"] = asdict(summary) metadata["changes"] = asdict(summary)
policy = evaluate_changes(summary, self.settings, workspace.path) policy = evaluate_changes(summary, self.settings, workspace.path)
@@ -101,6 +128,9 @@ class AutomationWorker:
results = [] results = []
test_environment = { test_environment = {
"AGENT_TEST_DB_ENABLED": "true", "AGENT_TEST_DB_ENABLED": "true",
"APP_ENV": "testing",
"APP_KEY": "base64:MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=",
"AI_MISTRAL_API_KEY": "automation-test-key",
"TEST_DB_HOST": self.settings.test_database_host, "TEST_DB_HOST": self.settings.test_database_host,
"TEST_DB_PORT": str(self.settings.test_database_port), "TEST_DB_PORT": str(self.settings.test_database_port),
"TEST_DB_NAME": self.settings.test_database_name, "TEST_DB_NAME": self.settings.test_database_name,

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

@@ -35,6 +35,7 @@ def settings(**overrides) -> Settings:
"poll_seconds": 60, "poll_seconds": 60,
"max_changed_files": 12, "max_changed_files": 12,
"max_changed_lines": 800, "max_changed_lines": 800,
"max_agent_attempts": 2,
"stale_claim_minutes": 90, "stale_claim_minutes": 90,
"dry_run": True, "dry_run": True,
} }
@@ -52,3 +53,21 @@ def test_pr_requires_gitea_token() -> None:
config = settings(mode="pr", dry_run=False) config = settings(mode="pr", dry_run=False)
with pytest.raises(ValueError, match="GITEA_TOKEN"): with pytest.raises(ValueError, match="GITEA_TOKEN"):
config.validate() config.validate()
def test_default_commands_initialize_disposable_laravel_schema(monkeypatch) -> None:
monkeypatch.setenv("FAILURE_DB_PASSWORD", "failure-secret")
monkeypatch.setenv("TEST_DB_PASSWORD", "test-secret")
monkeypatch.delenv("TEST_COMMANDS", raising=False)
config = Settings.from_env()
assert "npm ci" in config.test_commands
assert "npm run build" in config.test_commands
assert "php artisan migrate:fresh --force" in config.test_commands
assert config.test_commands[-1] == "php artisan test"
def test_agent_attempts_are_bounded() -> None:
with pytest.raises(ValueError, match="MAX_AGENT_ATTEMPTS"):
settings(max_agent_attempts=4).validate()

View File

@@ -1,5 +1,5 @@
from lai_automation.models import FailureReport from lai_automation.models import FailureReport
from lai_automation.prompt import build_task, redact from lai_automation.prompt import build_retry_task, build_task, redact
def test_redact_removes_common_secrets_and_personal_markers() -> None: def test_redact_removes_common_secrets_and_personal_markers() -> None:
@@ -51,3 +51,77 @@ def test_redact_removes_extended_credentials() -> None:
assert "eyJhbGci" not in result assert "eyJhbGci" not in result
assert "database-password" not in result assert "database-password" not in result
assert "private-material" not in result assert "private-material" not in result
def test_retry_task_contains_failure_without_secrets() -> None:
report = FailureReport(
id=8,
chat_message_id=1,
source="user_feedback",
category="incorrect_answer",
user_feedback="Wrong result",
feedback_type="wrong_result",
expected_behavior="Use a weighted average",
prompt="Average sale price",
response="Zero",
sql_query="SELECT AVG(list_price)",
error_message=None,
context={"database_type": "erp"},
)
task = build_retry_task(report, RuntimeError("Missing import; token=secret-value"))
assert "Missing import" in task
assert "secret-value" not in task
assert "Do not remove or weaken tests" in task
assert "unrelated production routing" in task
assert "Use a weighted average" in task
assert "SELECT AVG(list_price)" in task
def test_incorrect_answer_task_guides_semantics_level_repair() -> None:
report = FailureReport(
id=42,
chat_message_id=1,
source="user_feedback",
category="incorrect_answer",
user_feedback="Wrong result",
feedback_type="wrong_result",
expected_behavior="Sum net values and divide by quantity",
prompt="Average sale price for article 9031",
response="0.00",
sql_query="SELECT AVG(Vk_preis) FROM FK_Artikel",
error_message=None,
context={"database_type": "erp"},
)
task = build_task(report)
assert "config/*_semantics.php" in task
assert "Do not hardcode" in task
assert "protected schema/business-" in task
assert "context builder" in task
assert "known-bad SQL" 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
assert "tenant_type" in task

View File

@@ -1,7 +1,9 @@
from unittest.mock import Mock from pathlib import Path
from unittest.mock import Mock, patch
from test_config import settings from test_config import settings
from lai_automation.git import ChangeSummary
from lai_automation.models import FailureReport from lai_automation.models import FailureReport
from lai_automation.worker import AutomationWorker from lai_automation.worker import AutomationWorker
@@ -49,8 +51,91 @@ def test_database_credentials_are_only_passed_to_test_process() -> None:
assert results[0]["returncode"] == 0 assert results[0]["returncode"] == 0
environment = workspace.run.call_args.kwargs["extra_environment"] environment = workspace.run.call_args.kwargs["extra_environment"]
assert environment["AGENT_TEST_DB_ENABLED"] == "true" assert environment["AGENT_TEST_DB_ENABLED"] == "true"
assert environment["APP_ENV"] == "testing"
assert environment["AI_MISTRAL_API_KEY"] == "automation-test-key"
assert environment["TEST_DB_USER"] == "test_runner" assert environment["TEST_DB_USER"] == "test_runner"
assert environment["TEST_DB_PASSWORD"] == "test-secret" assert environment["TEST_DB_PASSWORD"] == "test-secret"
assert environment["DB_DATABASE"] == "lai_agent_test" assert environment["DB_DATABASE"] == "lai_agent_test"
assert environment["ERP_DATABASE"] == "customer_agent_test" assert environment["ERP_DATABASE"] == "customer_agent_test"
assert environment["COCKPIT_CONFIG_DATABASE"] == "cockpit_agent_test" assert environment["COCKPIT_CONFIG_DATABASE"] == "cockpit_agent_test"
def test_failed_validation_gets_one_correction_before_pr() -> None:
report = FailureReport(
id=13,
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="SELECT missing_column FROM invoice_row",
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 = ["first change", "corrected change"]
worker.gitea = Mock()
worker._run_tests = Mock(side_effect=[RuntimeError("Missing import"), []])
workspace = Mock()
workspace.path = Path("/tmp/nonexistent-workspace")
workspace.branch = "ai/failure-13"
workspace.summary.return_value = 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 "Missing import" in worker.agent.run.call_args_list[1].args[1]
assert worker._run_tests.call_count == 2
workspace.push_branch.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()