Support native fix-agent assignments and final reviewed patches

This commit is contained in:
Jonathan Singer 2026-09-29 22:36:47 -04:00
parent d184142377
commit 348fbf2608
6 changed files with 49 additions and 16 deletions

View file

@ -668,6 +668,7 @@ def build_strix_agent(
system_prompt_context: dict[str, Any] | None = None,
extra_tools: Sequence[Tool] | None = None,
instructions_override: str | None = None,
base_tools: Sequence[Tool] | None = None,
) -> SandboxAgent[Any]:
"""Build a SandboxAgent for either root or child use.
@ -680,6 +681,8 @@ def build_strix_agent(
registered via ``register_agent_tools``.
instructions_override: Use this verbatim as the system prompt instead
of rendering the built-in scan prompt.
base_tools: Replace the scan toolset (including registered scan extras)
for specialized assignments. Filesystem, shell and completion remain available.
"""
if instructions_override is not None:
instructions = instructions_override
@ -694,14 +697,15 @@ def build_strix_agent(
system_prompt_context=system_prompt_context,
)
agent_tools = [*_EXTRA_TOOLS, *(extra_tools or [])]
selected_tools = list(_BASE_TOOLS if base_tools is None else base_tools)
agent_tools = [*(_EXTRA_TOOLS if base_tools is None else []), *(extra_tools or [])]
if interactive:
# Yielding to the user is only meaningful when one is attached.
agent_tools.append(respond_to_user)
if is_root:
tools: list[Tool] = [*_BASE_TOOLS, *agent_tools, finish_scan]
tools: list[Tool] = [*selected_tools, *agent_tools, finish_scan]
else:
tools = [*_BASE_TOOLS, *agent_tools, agent_finish]
tools = [*selected_tools, *agent_tools, agent_finish]
_ensure_unique_tool_names(tools)
tools = [
_with_bounded_result(_with_strictness(_with_coerced_arguments(tool), strict_tool_schemas))

View file

@ -36,6 +36,7 @@ from strix.fix.prepare import (
build_git_manifest,
build_git_patch,
prepare_fix,
workspace_digest,
)
@ -73,4 +74,5 @@ __all__ = [
"build_git_patch",
"candidate_from_legacy_report",
"prepare_fix",
"workspace_digest",
]

View file

@ -315,7 +315,7 @@ async def _tracked_in_index(workspace: Path, path: str) -> bool:
return await process.wait() == 0
async def _workspace_digest(workspace: Path) -> str:
async def workspace_digest(workspace: Path) -> str:
manifest, _, _ = await build_git_manifest(workspace)
payload = json.dumps(
[entry.model_dump(mode="json") for entry in manifest],
@ -374,7 +374,11 @@ def _result(
state=state,
validation_mode="agent_review",
prepared_source_digest=(
context.feedback[-1].repair.source_digest if context.feedback else None
verifier.source_digest
if verifier is not None
else context.feedback[-1].repair.source_digest
if context.feedback
else None
),
test_plan=context.feedback[-1].repair.test_plan if context.feedback else None,
stop_reason=reason,
@ -475,14 +479,14 @@ async def prepare_fix( # noqa: PLR0915 - thin orchestration and cleanup
record = FixPreparationAttempt(
attempt=context.attempt,
repair=RepairOutcome(status=RepairStatus.INCOMPLETE, summary="Repair started."),
workspace_digest=await _workspace_digest(workspace),
workspace_digest=await workspace_digest(workspace),
)
context.feedback.append(record)
record.repair = _repair_outcome(await repair(context, checks))
repair_turns += max(1, record.repair.turns_used)
checks = await evidence_reader() if evidence_reader else record.repair.command_results
record.checks = list(checks)
record.workspace_digest = await _workspace_digest(workspace)
record.workspace_digest = await workspace_digest(workspace)
manifest, _, _ = await manifest_builder(workspace)
if record.repair.status is not RepairStatus.COMPLETE:
return await finish(
@ -518,15 +522,12 @@ async def prepare_fix( # noqa: PLR0915 - thin orchestration and cleanup
if verifier.decision is not VerificationDecision.VERIFIED:
return await finish(PreparationState.BLOCKED, verifier.summary, gaps=verifier.gaps)
# Test selection, failures, reruns and coverage belong to the reviewer.
# Only the artifact identity is checked here; it never starts another repair.
if (
record.workspace_digest != await _workspace_digest(workspace)
or verifier.source_digest != record.repair.source_digest
):
# Review may correct the patch. Approval binds to its final snapshot,
# not the earlier repair checkpoint.
if verifier.source_digest != await workspace_digest(workspace):
return await finish(
PreparationState.BLOCKED,
"The deliverable changed during review; "
"the approved patch cannot be delivered.",
"The deliverable changed after review; the approved patch cannot be delivered.",
)
return await finish(
PreparationState.READY,

View file

@ -619,6 +619,7 @@ async def agent_finish(
success: bool = True,
report_to_parent: bool = True,
final_recommendations: list[str] | None = None,
outcome: str | None = None,
) -> str:
"""Subagent termination — post a completion report to the parent.
@ -669,8 +670,12 @@ async def agent_finish(
final_recommendations: Optional next-step suggestions for the
parent (e.g., "prioritize testing X", "spawn an agent to
cover Y").
outcome: Optional assignment-specific outcome, when requested by the caller.
"""
inner = _ctx(ctx)
allowed_outcomes = inner.get("completion_outcomes")
if allowed_outcomes is not None and outcome not in allowed_outcomes:
return json.dumps({"success": False, "error": f"Choose an outcome: {allowed_outcomes}"})
coordinator = coordinator_from_context(inner)
me = inner.get("agent_id")
if coordinator is None or me is None:
@ -745,6 +750,10 @@ async def agent_finish(
"parent_notified": parent_notified,
"agent_id": me,
"summary": result_summary,
"outcome": outcome,
"task_success": success,
"open_items": list(open_items or []),
"recommendations": list(final_recommendations or []),
"filed_report_ids": filed_report_ids,
"findings_count": len(findings or []),
"open_items_count": len(open_items or []),

View file

@ -11,6 +11,7 @@ from agents.tool import CustomTool, FunctionTool
from strix.agents import factory
from strix.config import load_settings
from strix.tools.thinking.tool import think
def _capturing_exec_tool(captured: dict[str, str]) -> FunctionTool:
@ -115,3 +116,19 @@ def test_function_tools_are_result_bounded() -> None:
by_name = {t.name: t for t in agent.tools}
assert getattr(by_name["think"], "_strix_bounded", False) is True
def test_specialized_tools_do_not_inherit_scan_or_registered_tools(monkeypatch) -> None:
extra = _capturing_exec_tool({})
extra.name = "scan_extension"
monkeypatch.setattr(factory, "_EXTRA_TOOLS", [extra])
default = factory.build_strix_agent(is_root=False)
specialized = factory.build_strix_agent(is_root=False, base_tools=[think])
default_names = {tool.name for tool in default.tools}
specialized_names = {tool.name for tool in specialized.tools}
assert {"scan_extension", "create_agent", "record_coverage"} <= default_names
assert {"think", "agent_finish"} <= specialized_names
assert (
not {"scan_extension", "create_agent", "record_coverage", "finish_scan"} & specialized_names
)

View file

@ -38,11 +38,11 @@ from strix.fix.prepare import (
PreparationContext,
PreparationPolicy,
_network_isolation_prefix,
_workspace_digest,
build_git_manifest,
build_git_patch,
prepare_fix,
run_command,
workspace_digest,
)
@ -193,7 +193,7 @@ async def _noop_repair(
),
*context.request.checks,
]
digest = await _workspace_digest(context.workspace)
digest = await workspace_digest(context.workspace)
results = []
for command in commands:
result = record_test_execution(