From 348fbf26087d12bd379eb2b1af50e5ce73eae30b Mon Sep 17 00:00:00 2001 From: Jonathan Singer Date: Tue, 29 Sep 2026 22:36:47 -0400 Subject: [PATCH] Support native fix-agent assignments and final reviewed patches --- strix/agents/factory.py | 10 +++++++--- strix/fix/__init__.py | 2 ++ strix/fix/prepare.py | 23 ++++++++++++----------- strix/tools/agents_graph/tools.py | 9 +++++++++ tests/test_agent_factory_shell.py | 17 +++++++++++++++++ tests/test_fix_preparation.py | 4 ++-- 6 files changed, 49 insertions(+), 16 deletions(-) diff --git a/strix/agents/factory.py b/strix/agents/factory.py index 063a5dd4a..f3564feac 100644 --- a/strix/agents/factory.py +++ b/strix/agents/factory.py @@ -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)) diff --git a/strix/fix/__init__.py b/strix/fix/__init__.py index 59c4c7e64..fcd99490d 100644 --- a/strix/fix/__init__.py +++ b/strix/fix/__init__.py @@ -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", ] diff --git a/strix/fix/prepare.py b/strix/fix/prepare.py index e13aba8da..c4de59f24 100644 --- a/strix/fix/prepare.py +++ b/strix/fix/prepare.py @@ -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, diff --git a/strix/tools/agents_graph/tools.py b/strix/tools/agents_graph/tools.py index da05bb1e7..ef3eae999 100644 --- a/strix/tools/agents_graph/tools.py +++ b/strix/tools/agents_graph/tools.py @@ -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 []), diff --git a/tests/test_agent_factory_shell.py b/tests/test_agent_factory_shell.py index 6bef1211c..24949ddae 100644 --- a/tests/test_agent_factory_shell.py +++ b/tests/test_agent_factory_shell.py @@ -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 + ) diff --git a/tests/test_fix_preparation.py b/tests/test_fix_preparation.py index 6cb0f40f2..26988d172 100644 --- a/tests/test_fix_preparation.py +++ b/tests/test_fix_preparation.py @@ -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(