Compare commits

3 Commits

Author SHA1 Message Date
c064c40be2 Merge pull request 'Replace Aider with a secure mini-SWE-agent worker' (#1) from feature/mini-swe-agent-worker into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #1
2026-08-31 17:39:42 +02:00
Christian Meyer
b4ad2abc33 feat: retry failed agent validations
Some checks failed
CI / test (push) Has been cancelled
CI / test (pull_request) Has been cancelled
2026-08-31 17:38:09 +02:00
Christian Meyer
4b5ac5f9d7 feat: improve worker diagnostics
Some checks failed
CI / test (push) Has been cancelled
CI / test (pull_request) Has been cancelled
2026-08-31 17:03:40 +02:00
12 changed files with 239 additions and 33 deletions

View File

@@ -11,6 +11,7 @@ GITEA_RUNNING_LABEL=ai:running
GITEA_READY_LABEL=ai:ready GITEA_READY_LABEL=ai:ready
GITEA_FAILED_LABEL=ai:failed GITEA_FAILED_LABEL=ai:failed
POLL_SECONDS=60 POLL_SECONDS=60
LOG_LEVEL=INFO
AGENT_MODEL=mistral/devstral-latest AGENT_MODEL=mistral/devstral-latest
MISTRAL_API_KEY= MISTRAL_API_KEY=
@@ -20,6 +21,7 @@ AGENT_COMMAND_TIMEOUT_SECONDS=180
AGENT_TIMEOUT_SECONDS=1200 AGENT_TIMEOUT_SECONDS=1200
AGENT_STEP_LIMIT=50 AGENT_STEP_LIMIT=50
AGENT_COST_LIMIT=5 AGENT_COST_LIMIT=5
MAX_AGENT_ATTEMPTS=3
SETUP_COMMANDS=composer install --no-interaction --prefer-dist;;npm ci SETUP_COMMANDS=composer install --no-interaction --prefer-dist;;npm ci
TEST_COMMANDS=npm run build;;php artisan test TEST_COMMANDS=npm run build;;php artisan test

View File

@@ -44,6 +44,15 @@ Start the worker with Docker Compose. Use `lai-agent check` inside the image for
validation, `lai-agent preflight` for clean-clone setup and tests without a model call, or validation, `lai-agent preflight` for clean-clone setup and tests without a model call, or
`lai-agent once` for a single polling cycle. `lai-agent once` for a single polling cycle.
Set `LOG_LEVEL=DEBUG` to include redacted output from setup and test commands. Agent runs always
log their model, configured limits, call count, cost, exit status, and trajectory path. `INFO` is
the default for normal operation.
`MAX_AGENT_ATTEMPTS` controls validation-driven self-repair. When an agent submission fails policy,
diff, or test validation, the worker preserves the workspace and gives the redacted failure output
to a fresh agent attempt. Only the final failed attempt marks the issue as `ai:failed` and removes
the workspace.
## Archived implementation ## Archived implementation
The previous database-polling Aider implementation is preserved unchanged in `old_2026-08-31/`. The previous database-polling Aider implementation is preserved unchanged in `old_2026-08-31/`.

View File

@@ -20,6 +20,8 @@ services:
AGENT_TIMEOUT_SECONDS: ${AGENT_TIMEOUT_SECONDS:-1200} AGENT_TIMEOUT_SECONDS: ${AGENT_TIMEOUT_SECONDS:-1200}
AGENT_STEP_LIMIT: ${AGENT_STEP_LIMIT:-50} AGENT_STEP_LIMIT: ${AGENT_STEP_LIMIT:-50}
AGENT_COST_LIMIT: ${AGENT_COST_LIMIT:-5} AGENT_COST_LIMIT: ${AGENT_COST_LIMIT:-5}
MAX_AGENT_ATTEMPTS: ${MAX_AGENT_ATTEMPTS:-3}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
AGENT_USER: coder AGENT_USER: coder
MISTRAL_API_KEY: ${MISTRAL_API_KEY:-} MISTRAL_API_KEY: ${MISTRAL_API_KEY:-}
POLL_SECONDS: ${POLL_SECONDS:-60} POLL_SECONDS: ${POLL_SECONDS:-60}

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import logging
from pathlib import Path from pathlib import Path
from minisweagent.agents.default import DefaultAgent from minisweagent.agents.default import DefaultAgent
@@ -9,6 +10,8 @@ from .config import Settings
from .models import AgentResult, Issue from .models import AgentResult, Issue
from .security import SafeLocalEnvironment from .security import SafeLocalEnvironment
logger = logging.getLogger(__name__)
_SYSTEM_TEMPLATE = """You are a careful coding agent working in one checked-out repository. _SYSTEM_TEMPLATE = """You are a careful coding agent working in one checked-out repository.
Solve only the supplied issue. Inspect existing code and tests before editing. Solve only the supplied issue. Inspect existing code and tests before editing.
Keep the patch minimal, add or update regression tests, and preserve public APIs Keep the patch minimal, add or update regression tests, and preserve public APIs
@@ -79,7 +82,19 @@ class MiniSWEAgentRunner:
issue: Issue, issue: Issue,
workspace: Path, workspace: Path,
trajectory_path: Path, trajectory_path: Path,
validation_feedback: str = "",
) -> AgentResult: ) -> AgentResult:
logger.info(
"Starting agent for issue #%s: model=%s step_limit=%s cost_limit=%.2f "
"wall_time_limit=%ss command_timeout=%ss trajectory=%s",
issue.number,
self.settings.agent_model,
self.settings.agent_step_limit,
self.settings.agent_cost_limit,
self.settings.agent_wall_time_seconds,
self.settings.agent_command_timeout_seconds,
trajectory_path,
)
model_kwargs: dict[str, object] = { model_kwargs: dict[str, object] = {
"timeout": min(self.settings.agent_wall_time_seconds, 600), "timeout": min(self.settings.agent_wall_time_seconds, 600),
} }
@@ -116,21 +131,41 @@ class MiniSWEAgentRunner:
max_consecutive_format_errors=3, max_consecutive_format_errors=3,
output_path=trajectory_path, output_path=trajectory_path,
) )
info = agent.run(self._task(issue)) info = agent.run(self._task(issue, validation_feedback))
return AgentResult( result = AgentResult(
exit_status=str(info.get("exit_status") or "unknown"), exit_status=str(info.get("exit_status") or "unknown"),
submission=str(info.get("submission") or ""), submission=str(info.get("submission") or ""),
model_calls=agent.n_calls, model_calls=agent.n_calls,
cost=float(agent.cost), cost=float(agent.cost),
) )
logger.info(
"Agent finished for issue #%s: status=%s model_calls=%s cost=%.4f "
"trajectory=%s",
issue.number,
result.exit_status,
result.model_calls,
result.cost,
trajectory_path,
)
return result
@staticmethod @staticmethod
def _task(issue: Issue) -> str: def _task(issue: Issue, validation_feedback: str = "") -> str:
return "\n".join( parts = [
f"Issue #{issue.number}: {issue.title}",
f"URL: {issue.html_url}",
"",
issue.body[:20_000],
]
if validation_feedback:
parts.extend(
[ [
f"Issue #{issue.number}: {issue.title}",
f"URL: {issue.html_url}",
"", "",
issue.body[:20_000], "The previous attempt failed deterministic validation. Keep the existing",
"workspace changes, diagnose and repair this exact failure, then rerun the",
"smallest relevant check and submit again:",
"",
validation_feedback[:8_000],
] ]
) )
return "\n".join(parts)

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import argparse import argparse
import logging import logging
import os
from .config import Settings from .config import Settings
from .worker import Worker from .worker import Worker
@@ -15,7 +16,7 @@ def main() -> None:
nargs="?", nargs="?",
default="worker", default="worker",
) )
parser.add_argument("--log-level", default="INFO") parser.add_argument("--log-level", default=os.getenv("LOG_LEVEL", "INFO"))
arguments = parser.parse_args() arguments = parser.parse_args()
logging.basicConfig( logging.basicConfig(
level=getattr(logging, arguments.log_level.upper(), logging.INFO), level=getattr(logging, arguments.log_level.upper(), logging.INFO),

View File

@@ -33,6 +33,7 @@ class Settings:
agent_wall_time_seconds: int agent_wall_time_seconds: int
agent_step_limit: int agent_step_limit: int
agent_cost_limit: float agent_cost_limit: float
max_agent_attempts: int
agent_user: str agent_user: str
setup_commands: tuple[str, ...] setup_commands: tuple[str, ...]
test_commands: tuple[str, ...] test_commands: tuple[str, ...]
@@ -75,6 +76,7 @@ class Settings:
agent_wall_time_seconds=int(os.getenv("AGENT_TIMEOUT_SECONDS", "1200")), agent_wall_time_seconds=int(os.getenv("AGENT_TIMEOUT_SECONDS", "1200")),
agent_step_limit=int(os.getenv("AGENT_STEP_LIMIT", "50")), agent_step_limit=int(os.getenv("AGENT_STEP_LIMIT", "50")),
agent_cost_limit=float(os.getenv("AGENT_COST_LIMIT", "5")), agent_cost_limit=float(os.getenv("AGENT_COST_LIMIT", "5")),
max_agent_attempts=int(os.getenv("MAX_AGENT_ATTEMPTS", "3")),
agent_user=os.getenv("AGENT_USER", "coder"), agent_user=os.getenv("AGENT_USER", "coder"),
setup_commands=_commands( setup_commands=_commands(
os.getenv( os.getenv(
@@ -156,6 +158,8 @@ class Settings:
raise ValueError("AGENT_STEP_LIMIT must be between 1 and 200") raise ValueError("AGENT_STEP_LIMIT must be between 1 and 200")
if self.agent_cost_limit < 0: if self.agent_cost_limit < 0:
raise ValueError("AGENT_COST_LIMIT cannot be negative") raise ValueError("AGENT_COST_LIMIT cannot be negative")
if not 1 <= self.max_agent_attempts <= 10:
raise ValueError("MAX_AGENT_ATTEMPTS must be between 1 and 10")
if not 1 <= self.max_changed_files <= 100: if not 1 <= self.max_changed_files <= 100:
raise ValueError("MAX_CHANGED_FILES must be between 1 and 100") raise ValueError("MAX_CHANGED_FILES must be between 1 and 100")
if not 1 <= self.max_changed_lines <= 10_000: if not 1 <= self.max_changed_lines <= 10_000:

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import logging
import os import os
import pwd import pwd
import re import re
@@ -15,6 +16,8 @@ from .config import Settings
from .models import ChangeSummary from .models import ChangeSummary
from .security import redact from .security import redact
logger = logging.getLogger(__name__)
_TRAILING_WHITESPACE = re.compile(r"^(.*):(\d+): trailing whitespace\.$") _TRAILING_WHITESPACE = re.compile(r"^(.*):(\d+): trailing whitespace\.$")
@@ -65,6 +68,8 @@ class GitWorkspace:
extra_environment: Mapping[str, str] | None = None, extra_environment: Mapping[str, str] | None = None,
timeout: int = 1800, timeout: int = 1800,
) -> subprocess.CompletedProcess[str]: ) -> subprocess.CompletedProcess[str]:
safe_command = redact(command, 1_000)
logger.info("Running trusted command: %s", safe_command)
environment, identity = self._safe_process_context(extra_environment) environment, identity = self._safe_process_context(extra_environment)
result = subprocess.run( result = subprocess.run(
command, command,
@@ -83,7 +88,15 @@ class GitWorkspace:
8_000, 8_000,
) )
detail = f":\n{output}" if output else "" detail = f":\n{output}" if output else ""
logger.error("Trusted command failed: %s%s", safe_command, detail)
raise RuntimeError(f"Command failed ({command}){detail}") raise RuntimeError(f"Command failed ({command}){detail}")
logger.info("Trusted command completed: %s", safe_command)
if result.stdout or result.stderr:
logger.debug(
"Trusted command output (%s):\n%s",
safe_command,
redact("\n".join(filter(None, (result.stdout, result.stderr))), 8_000),
)
return result return result
def summary(self) -> ChangeSummary: def summary(self) -> ChangeSummary:

View File

@@ -87,36 +87,54 @@ class Worker:
extra_environment=self.settings.test_environment, extra_environment=self.settings.test_environment,
) )
timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") validation_feedback = ""
trajectory = ( result = None
self.settings.workspace_root summary = None
/ "trajectories" for attempt in range(1, self.settings.max_agent_attempts + 1):
/ f"issue-{issue.number}-{timestamp}.traj.json" timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S%fZ")
) trajectory = (
result = self.agent.run(current_issue, workspace.path, trajectory) self.settings.workspace_root
if result.exit_status != "Submitted": / "trajectories"
raise RuntimeError(f"Agent stopped with status {result.exit_status}") / f"issue-{issue.number}-attempt-{attempt}-{timestamp}.traj.json"
)
summary = workspace.summary()
normalized = workspace.normalize_changed_whitespace(summary)
if normalized:
logger.info( logger.info(
"Normalized trailing whitespace in %s changed file(s) for issue #%s", "Starting attempt %s/%s for issue #%s",
len(normalized), attempt,
self.settings.max_agent_attempts,
issue.number, issue.number,
) )
summary = workspace.summary() started_at = time.monotonic()
validate_changes(workspace, summary, self.settings) result = self.agent.run(
workspace.check_diff(summary) current_issue,
for command in self.settings.test_commands: workspace.path,
workspace.run_trusted( trajectory,
command, validation_feedback,
extra_environment=self.settings.test_environment,
) )
elapsed = time.monotonic() - started_at
try:
if result.exit_status != "Submitted":
details = self._agent_failure_details(result, elapsed, trajectory)
raise RuntimeError(
f"Agent stopped with status {result.exit_status}: {details}"
)
summary = self._validate_agent_changes(workspace, issue.number)
break
except Exception as validation_error:
if attempt >= self.settings.max_agent_attempts:
raise
validation_feedback = redact(str(validation_error), 8_000)
logger.warning(
"Attempt %s/%s for issue #%s failed validation; preserving "
"workspace for repair attempt %s: %s",
attempt,
self.settings.max_agent_attempts,
issue.number,
attempt + 1,
validation_feedback,
)
summary = workspace.summary() if result is None or summary is None:
validate_changes(workspace, summary, self.settings) raise RuntimeError("Agent attempts ended without a validated change")
workspace.check_diff(summary)
commit = workspace.commit() commit = workspace.commit()
workspace.push() workspace.push()
@@ -177,6 +195,50 @@ class Worker:
finally: finally:
workspace.cleanup() workspace.cleanup()
def _validate_agent_changes(self, workspace: GitWorkspace, issue_number: int):
summary = workspace.summary()
normalized = workspace.normalize_changed_whitespace(summary)
if normalized:
logger.info(
"Normalized trailing whitespace in %s changed file(s) for issue #%s",
len(normalized),
issue_number,
)
summary = workspace.summary()
validate_changes(workspace, summary, self.settings)
workspace.check_diff(summary)
for command in self.settings.test_commands:
workspace.run_trusted(
command,
extra_environment=self.settings.test_environment,
)
summary = workspace.summary()
validate_changes(workspace, summary, self.settings)
workspace.check_diff(summary)
return summary
def _agent_failure_details(
self,
result,
elapsed: float,
trajectory,
) -> str:
limits: list[str] = []
if result.model_calls >= self.settings.agent_step_limit:
limits.append(f"step limit reached ({self.settings.agent_step_limit})")
if self.settings.agent_cost_limit and result.cost >= self.settings.agent_cost_limit:
limits.append(f"cost limit reached ({self.settings.agent_cost_limit:.2f})")
if elapsed >= self.settings.agent_wall_time_seconds:
limits.append(
f"wall-time limit reached ({self.settings.agent_wall_time_seconds}s)"
)
reason = ", ".join(limits) or "limit source not reported by mini-SWE-agent"
return (
f"{reason}; model_calls={result.model_calls}; cost={result.cost:.4f}; "
f"elapsed={elapsed:.1f}s; trajectory={trajectory}"
)
def _pull_request_body( def _pull_request_body(
self, self,
issue: Issue, issue: Issue,

View File

@@ -30,6 +30,7 @@ def settings(tmp_path: Path) -> Settings:
agent_wall_time_seconds=1200, agent_wall_time_seconds=1200,
agent_step_limit=40, agent_step_limit=40,
agent_cost_limit=5.0, agent_cost_limit=5.0,
max_agent_attempts=1,
agent_user="coder", agent_user="coder",
setup_commands=("composer install",), setup_commands=("composer install",),
test_commands=("php artisan test",), test_commands=("php artisan test",),

View File

@@ -20,6 +20,21 @@ def test_task_uses_issue_text_not_github_parser() -> None:
assert issue.body in task assert issue.body in task
def test_task_includes_validation_feedback_for_repair_attempt() -> None:
issue = Issue(
number=43,
title="Broken query",
body="Original report",
html_url="https://gitea.example.test/owner/lai/issues/43",
labels=(),
)
task = MiniSWEAgentRunner._task(issue, "PHP parse error on line 3")
assert "previous attempt failed deterministic validation" in task
assert "PHP parse error on line 3" in task
def test_system_prompt_requires_precise_diff_hygiene() -> None: def test_system_prompt_requires_precise_diff_hygiene() -> None:
assert "Never create backup files" in _SYSTEM_TEMPLATE assert "Never create backup files" in _SYSTEM_TEMPLATE
assert "failure-report transport code" in _SYSTEM_TEMPLATE assert "failure-report transport code" in _SYSTEM_TEMPLATE

View File

@@ -30,6 +30,7 @@ def test_settings_load_issue_workflow_defaults(
assert settings.queued_label == "ai:queued" assert settings.queued_label == "ai:queued"
assert settings.running_label == "ai:running" assert settings.running_label == "ai:running"
assert settings.agent_model == "mistral/devstral-latest" assert settings.agent_model == "mistral/devstral-latest"
assert settings.max_agent_attempts == 3
assert settings.test_commands == ("npm run build", "php artisan test") assert settings.test_commands == ("npm run build", "php artisan test")
assert settings.test_environment["DB_DATABASE"] == "lai_agent_test" assert settings.test_environment["DB_DATABASE"] == "lai_agent_test"
assert settings.test_environment["ERP_DATABASE"] == "customer_agent_test" assert settings.test_environment["ERP_DATABASE"] == "customer_agent_test"

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
from unittest.mock import Mock, call, patch from unittest.mock import Mock, call, patch
from lai_automation.models import ( from lai_automation.models import (
@@ -158,6 +159,66 @@ def test_run_once_returns_false_without_work(settings) -> None:
assert Worker(settings, gitea, Mock()).run_once() is False assert Worker(settings, gitea, Mock()).run_once() is False
def test_agent_failure_details_identify_step_limit(settings) -> None:
worker = Worker(settings, Mock(), Mock())
result = AgentResult("LimitsExceeded", "", settings.agent_step_limit, 0.2)
details = worker._agent_failure_details(
result,
elapsed=180.0,
trajectory=Path("/workspaces/trajectories/issue-43.traj.json"),
)
assert f"step limit reached ({settings.agent_step_limit})" in details
assert "model_calls=40" in details
assert "cost=0.2000" in details
assert "issue-43.traj.json" in details
@patch("lai_automation.worker.GitWorkspace")
def test_failed_validation_is_given_to_repair_attempt(
GitWorkspace,
settings,
) -> None:
settings = settings.__class__(
**{**settings.__dict__, "max_agent_attempts": 2}
)
issue = queued_issue()
running = Issue(**{**issue.__dict__, "labels": (Label(2, "ai:running"),)})
gitea = Mock()
gitea.get_issue.return_value = issue
gitea.set_automation_state.side_effect = [running, running]
gitea.create_pull_request.return_value = PullRequest(
18,
"https://gitea.example.test/owner/lai/pulls/18",
)
agent = Mock()
agent.run.side_effect = [
AgentResult("Submitted", "First", 4, 0.2),
AgentResult("Submitted", "Repaired", 2, 0.1),
]
workspace = GitWorkspace.return_value
workspace.path = settings.workspace_root / "issue-7" / "repo"
workspace.branch = "ai/issue-7"
workspace.summary.return_value = ChangeSummary(("app/Fix.php",), 2, 0)
workspace.normalize_changed_whitespace.return_value = ()
workspace.has_symlink.return_value = False
workspace.has_path.return_value = False
workspace.run_trusted.side_effect = [
Mock(),
RuntimeError("PHP parse error on line 3"),
Mock(),
]
workspace.commit.return_value = "abc123"
Worker(settings, gitea, agent).process(issue, labels())
assert agent.run.call_count == 2
assert "PHP parse error on line 3" in agent.run.call_args_list[1].args[3]
workspace.push.assert_called_once()
assert gitea.set_automation_state.call_args_list[-1].args[1] == "ai:ready"
@patch("lai_automation.worker.GitWorkspace") @patch("lai_automation.worker.GitWorkspace")
def test_preflight_runs_setup_and_tests_without_model( def test_preflight_runs_setup_and_tests_without_model(
GitWorkspace, GitWorkspace,