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
32 changes: 32 additions & 0 deletions README-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,38 @@ fork branch still points at the validated commit before it resumes at MR
creation. Routing-invariant failures are terminal and are not requeued.


### Copr build validation

Backport (including Y-stream inheritance), rebase, MR updates, and MR
consolidation use a shared `BuildWorkflow`. Its `execute_build` step calls
`build_package` through the existing MCP gateway; the tool submits and polls
Copr as before. Shared `run_tool()` validates the response as a `BuildResult`
using its opt-in `expected_output` schema; other callers' raw results are unchanged.
Successful validation makes no build-stage LLM calls. Timeouts, tool errors,
and failures without log artifacts are returned directly to the workflow.

For a failed build with log artifacts, the workflow runs `diagnose_failure`,
where `BuildFailureAnalyst` explains the existing failure. It cannot invoke
`build_package` or change the outcome flags; workflow code owns success, timeout,
and infrastructure-error classification.
Gateway-based diagnosis requires both `download_artifacts` and
`extract_log_snippets`. If either is missing, the analyst retrieves and inspects
the log URLs in its local sandbox instead.
If diagnosis fails, the original build error is retained. Callers keep their
existing retry and timeout policies: ordinary backport/rebase/MR-update paths
may proceed after a timeout, while inheritance and consolidation require success.

Consolidations without Jira footers derive their Copr project name from
`consolidation-{package}-{branch}`, replacing unsupported characters and limiting
the name to 100 characters. Changed names include a hash suffix to distinguish
packages and branches across sanitization or truncation. This fallback is stable
across retries and is not added to Jira metadata or commit footers.

Standalone rebuild does not have this Copr validation stage. The backport
incremental repair agent's own edit/build loop is also unchanged. Dry-run mode
still performs real build validation; it does not turn a build into a success
without running it.

## Dry run mode

**Without setting `DRY_RUN=true` env var, agents will make real changes:**
Expand Down
16 changes: 11 additions & 5 deletions docs/mr_consolidation_architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,11 +168,17 @@ If the agent reports failure, the workflow transitions to `handle_failure`.

### Step 4: `run_build_agent`

A separate build agent verifies the SRPM produced by the consolidation agent actually
builds in the target build system (Koji/Brew scratch build). If the build fails, the
workflow loops back to `run_consolidation_agent` with the build error message, up to
`max_build_attempts` times (default 3). This mirrors the retry pattern used by the
backport agent.
The shared `BuildWorkflow` submits the prepared SRPM to Copr deterministically,
using an LLM only to diagnose failed builds with logs. The Copr project name is
the first Jira key collected from commit footers, or
`consolidation-{package}-{branch}` when there are no Jira footers. Unsupported
characters are replaced and generated names are limited to 100 characters, with
a hash suffix when changed to distinguish packages and branches. The fallback
stays stable across retries and does not populate Jira metadata or commit footers.

On failure, the workflow retries the selected consolidation flow with the build
error, up to `max_build_attempts` times (default 3). Consolidation requires a
successful build; timeouts do not permit publication.

### Step 5: `stage_changes`

Expand Down
42 changes: 15 additions & 27 deletions ymir/agents/backport_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,7 @@
from specfile import Specfile

import ymir.agents.tasks as tasks
from ymir.agents.build_agent import create_build_agent
from ymir.agents.build_agent import get_prompt as get_build_prompt
from ymir.agents.build_agent import run_build
from ymir.agents.constants import (
I_AM_YMIR,
ZSTREAM_TARGET_LABEL,
Expand Down Expand Up @@ -85,7 +84,6 @@
BackportInputSchema,
BackportOutputSchema,
BuildInputSchema,
BuildOutputSchema,
ErrorData,
ErrorListEntry,
InheritAdaptationInputSchema,
Expand Down Expand Up @@ -1124,20 +1122,15 @@ async def run_build_agent(state):
)
return "comment_in_jira"

fresh_build_agent = create_build_agent(gateway_tools, local_tool_options)
response = await fresh_build_agent.run(
render_template(
get_build_prompt(),
BuildInputSchema(
srpm_path=state.backport_result.srpm_path,
dist_git_branch=state.dist_git_branch,
jira_issue=state.jira_issue,
),
build_result = await run_build(
build_input=BuildInputSchema(
srpm_path=state.backport_result.srpm_path,
dist_git_branch=state.dist_git_branch,
jira_issue=state.jira_issue,
),
expected_output=BuildOutputSchema,
**get_agent_execution_config(),
available_tools=gateway_tools,
local_tool_options=local_tool_options,
)
build_result = BuildOutputSchema.model_validate_json(response.last_message.text)
if build_result.success:
state.incremental_fix_attempts = 0
return "update_release"
Expand Down Expand Up @@ -1171,20 +1164,15 @@ async def run_build_agent(state):

async def run_inherit_build_agent(state):
"""Require a successful Copr validation before publishing inheritance."""
fresh_build_agent = create_build_agent(gateway_tools, local_tool_options)
response = await fresh_build_agent.run(
render_template(
get_build_prompt(),
BuildInputSchema(
srpm_path=state.backport_result.srpm_path,
dist_git_branch=state.dist_git_branch,
jira_issue=state.jira_issue,
),
build_result = await run_build(
build_input=BuildInputSchema(
srpm_path=state.backport_result.srpm_path,
dist_git_branch=state.dist_git_branch,
jira_issue=state.jira_issue,
),
expected_output=BuildOutputSchema,
**get_agent_execution_config(),
available_tools=gateway_tools,
local_tool_options=local_tool_options,
)
build_result = BuildOutputSchema.model_validate_json(response.last_message.text)
if build_result.success:
return "stage_changes"

Expand Down
176 changes: 147 additions & 29 deletions ymir/agents/build_agent.py
Original file line number Diff line number Diff line change
@@ -1,32 +1,146 @@
import asyncio
import logging
from typing import Any
from urllib.parse import urlsplit

from beeai_framework.agents.requirement.requirements.conditional import (
ConditionalRequirement,
)
from beeai_framework.memory import UnconstrainedMemory
from beeai_framework.middleware.trajectory import GlobalTrajectoryMiddleware
from beeai_framework.tools import Tool
from beeai_framework.tools import Tool, ToolError
from beeai_framework.tools.think import ThinkTool
from beeai_framework.utils.cancellation import AbortController
from beeai_framework.workflows import Workflow
from pydantic import BaseModel

from ymir.agents.reasoning_agent import ReasoningAgent
from ymir.agents.utils import (
get_agent_execution_config,
get_chat_model,
get_tool_call_checker_config,
is_reasoning_enabled,
render_template,
run_tool,
)
from ymir.common.logging_setup import get_trajectory_writeable
from ymir.common.models import BuildInstructionsInput
from ymir.common.models import (
BuildFailureAnalysisInput,
BuildFailureAnalysisOutput,
BuildInputSchema,
BuildInstructionsInput,
BuildOutputSchema,
BuildResult,
)
from ymir.tools.unprivileged.commands import RunShellCommandTool
from ymir.tools.unprivileged.filesystem import GetCWDTool
from ymir.tools.unprivileged.text import (
CreateTool,
InsertAfterSubstringTool,
InsertTool,
SearchTextTool,
StrReplaceTool,
ViewTool,
)
from ymir.tools.unprivileged.text import SearchTextTool, ViewTool

logger = logging.getLogger(__name__)


class BuildState(BaseModel):
build_input: BuildInputSchema
build_result: BuildResult | None = None
output: BuildOutputSchema | None = None


async def run_build(
*,
build_input: BuildInputSchema,
available_tools: list[Tool],
local_tool_options: dict[str, Any],
) -> BuildOutputSchema:
Comment thread
opohorel marked this conversation as resolved.
"""Submit one build through the gateway, diagnosing only failures with logs.

Copr submission retries and polling remain inside the existing tool. In
particular, neither diagnosis nor a failed diagnosis resubmits the build.
Callers retain ownership of timeout policy and workflow-level retries.
A missing advertised build tool is reported as an infrastructure failure.
"""
workflow = Workflow(BuildState, name="BuildWorkflow")

async def execute_build(state: BuildState) -> str:
try:
result = await run_tool(
"build_package",
available_tools=available_tools,
expected_output=BuildResult,
**state.build_input.model_dump(mode="json"),
)
except ToolError as e:
logger.warning("Copr build tool failed for %s: %s", state.build_input.jira_issue, e)
state.output = BuildOutputSchema(success=False, error=str(e), is_infra_error=True)
return Workflow.END

state.build_result = result
logger.info(
"Copr build result for %s: success=%s, is_timeout=%s",
state.build_input.jira_issue,
result.success,
result.is_timeout,
)
if result.success:
state.output = BuildOutputSchema(success=True, error=None)
return Workflow.END

state.output = BuildOutputSchema(
success=False,
error=result.error_message or "Copr build failed without an error message",
is_timeout=result.is_timeout,
)
if result.is_timeout:
return Workflow.END

if not any(urlsplit(url).path.endswith(".log.gz") for url in result.artifacts_urls or []):
return Workflow.END

return "diagnose_failure"

async def diagnose_failure(state: BuildState) -> str:
if state.build_result is None or state.output is None:
raise RuntimeError("Build failure diagnosis requires an existing build result")
try:
analyst = create_build_failure_agent(available_tools, local_tool_options)
response = await analyst.run(
render_template(
get_prompt(),
BuildFailureAnalysisInput(
**state.build_input.model_dump(), build_result=state.build_result
),
),
expected_output=BuildFailureAnalysisOutput,
**get_agent_execution_config(),
)
diagnosis = BuildFailureAnalysisOutput.model_validate_json(response.last_message.text)
state.output.error = diagnosis.error
except asyncio.CancelledError:
# BeeAI AbortError also inherits Exception; preserve worker cancellation.
raise
except Exception:
# A model outage or invalid answer cannot erase a known build failure.
logger.exception(
"Could not analyze failed build for %s; retaining tool error", state.build_input.jira_issue
)
return Workflow.END

workflow.add_step("execute_build", execute_build)
workflow.add_step("diagnose_failure", diagnose_failure)
controller = AbortController()
task = asyncio.ensure_future(
workflow.run(BuildState(build_input=build_input), options={"signal": controller.signal})
)
try:
response = await asyncio.shield(task)
except asyncio.CancelledError:
# BeeAI runs steps in a child task. Abort and drain that task before
# returning cancellation so diagnosis cannot outlive its caller.
controller.abort("Build workflow caller cancelled")
await asyncio.gather(task, return_exceptions=True)
raise
if response.state.output is None:
raise RuntimeError("Build workflow finished without a result")
return response.state.output


def get_instructions(*, has_extract_log_snippets: bool = False) -> str:
Expand All @@ -40,12 +154,12 @@ def get_prompt() -> str:
return "build/prompt.j2"


def create_build_agent(mcp_tools: list[Tool], local_tool_options: dict[str, Any]) -> ReasoningAgent:
filtered_mcp_tools = [
t for t in mcp_tools if t.name in ["build_package", "download_artifacts", "extract_log_snippets"]
]
available_tool_names = {t.name for t in filtered_mcp_tools}
has_extract_log_snippets = "extract_log_snippets" in available_tool_names
def create_build_failure_agent(mcp_tools: list[Tool], local_tool_options: dict[str, Any]) -> ReasoningAgent:
gateway_log_tools = {"download_artifacts", "extract_log_snippets"}
has_gateway_log_tools = gateway_log_tools <= {t.name for t in mcp_tools}
filtered_mcp_tools = (
[t for t in mcp_tools if t.name in gateway_log_tools] if has_gateway_log_tools else []
)

requirements = [
ConditionalRequirement(
Expand All @@ -55,34 +169,38 @@ def create_build_agent(mcp_tools: list[Tool], local_tool_options: dict[str, Any]
consecutive_allowed=False,
only_success_invocations=False,
),
ConditionalRequirement("build_package", min_invocations=1),
ConditionalRequirement("download_artifacts", only_after=["build_package"]),
]
if "extract_log_snippets" in available_tool_names:
if has_gateway_log_tools:
requirements.append(
ConditionalRequirement("extract_log_snippets", only_after=["download_artifacts"]),
)

# Gateway-downloaded logs are not in the agent sandbox. Use that path only
# when both tools are available; otherwise retrieve logs locally from URLs.
local_tools = (
[]
if has_gateway_log_tools
else [
Comment thread
opohorel marked this conversation as resolved.
RunShellCommandTool(options=local_tool_options),
ViewTool(options=local_tool_options),
SearchTextTool(options=local_tool_options),
GetCWDTool(options=local_tool_options),
]
)

return ReasoningAgent(
name="BuildAgent",
name="BuildFailureAnalyst",
llm=get_chat_model(),
unconstrained=is_reasoning_enabled(),
tool_call_checker=get_tool_call_checker_config(),
tools=[
ThinkTool(),
RunShellCommandTool(options=local_tool_options),
CreateTool(options=local_tool_options),
ViewTool(options=local_tool_options),
InsertTool(options=local_tool_options),
InsertAfterSubstringTool(options=local_tool_options),
StrReplaceTool(options=local_tool_options),
SearchTextTool(options=local_tool_options),
GetCWDTool(options=local_tool_options),
*local_tools,
*filtered_mcp_tools,
],
memory=UnconstrainedMemory(),
requirements=requirements,
middlewares=[GlobalTrajectoryMiddleware(pretty=True, target=get_trajectory_writeable())],
role="Red Hat Enterprise Linux developer",
instructions=get_instructions(has_extract_log_snippets=has_extract_log_snippets),
instructions=get_instructions(has_extract_log_snippets=has_gateway_log_tools),
)
Loading
Loading