From b181f6267f8b8924395c57e663d32f1b65951ec9 Mon Sep 17 00:00:00 2001 From: Abhishek Bhandwaldar Date: Fri, 14 Aug 2026 13:55:27 -0400 Subject: [PATCH 1/2] feat: add --focus flag to `factory workflow run` and allow --focus for deep-research mode - Add --focus argument to `factory workflow run` CLI parser - Inject focus query into all AgentNode node_context when --focus is provided - Add deep-research to the --focus allowlist in CEO flag validation This enables running deep-research with a specific research topic: factory workflow run deep-research /path --focus "topic" factory ceo /path --mode deep-research --focus "topic" Co-Authored-By: Claude Opus 4.6 (1M context) --- factory/cli/_ceo_helpers.py | 4 ++-- factory/workflow/cli.py | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/factory/cli/_ceo_helpers.py b/factory/cli/_ceo_helpers.py index b1df3add2..724c446b0 100644 --- a/factory/cli/_ceo_helpers.py +++ b/factory/cli/_ceo_helpers.py @@ -454,10 +454,10 @@ def _validate_late_flags( ) return 1 - if focus and mode not in ("improve", "research", "create", "evolve", "study", "frontend-design", "frontend-design-discover") and not design_existing and not just_plan: + if focus and mode not in ("improve", "research", "create", "evolve", "study", "frontend-design", "frontend-design-discover", "deep-research") and not design_existing and not just_plan: print( f"Error: --focus (targeted mode) only works in improve, research, create, evolve, study, frontend-design, " - f"frontend-design-discover, or design (with --just-plan) mode, " + f"frontend-design-discover, deep-research, or design (with --just-plan) mode, " f"got '{mode}'. The project must already be built before targeting specific items.", file=sys.stderr, ) diff --git a/factory/workflow/cli.py b/factory/workflow/cli.py index 6ac477a20..f9451928f 100644 --- a/factory/workflow/cli.py +++ b/factory/workflow/cli.py @@ -87,6 +87,12 @@ def _cmd_run(args: argparse.Namespace) -> int: dry_run=dry_run, ) + focus = getattr(args, "focus", None) + if focus: + for node_id, node in wf.nodes.items(): + if isinstance(node, AgentNode): + executor.node_context[node_id] = f"Research topic: {focus}" + from factory.agents.runner import begin_cycle_session, complete_cycle_session cycle_span_id = begin_cycle_session(project_path, cycle_id=name) @@ -346,6 +352,7 @@ def add_workflow_parser(sub: argparse._SubParsersAction[argparse.ArgumentParser] p.add_argument("name", help="Workflow name (build, design, improve, research, meta)") p.add_argument("project_path", help="Path to the project") p.add_argument("--dry-run", action="store_true", help="Execute without real agent calls") + p.add_argument("--focus", default=None, help="Research topic or focus query passed to agent nodes") p.add_argument( "--from-yaml", default=None, metavar="PATH", help="Load workflow from YAML annotations file (overrides slot values on base workflow)", From 4c09274de3e9eb7ab660b819c823b9bed3a31a04 Mon Sep 17 00:00:00 2001 From: Abhishek Bhandwaldar Date: Fri, 14 Aug 2026 16:16:02 -0400 Subject: [PATCH 2/2] test: add coverage for --focus flag in workflow run and CEO validation - test_focus_injects_node_context: verifies focus string reaches AgentNode node_context - test_no_focus_leaves_node_context_empty: verifies no injection without --focus - test_focus_only_targets_agent_nodes: verifies FnNode/GateNode/Study are skipped - test_focus_accepted_with_deep_research_mode: verifies deep-research is in allowlist Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/test_study.py | 13 ++++++ tests/test_workflow_cli.py | 92 +++++++++++++++++++++++++++++++++++++- 2 files changed, 103 insertions(+), 2 deletions(-) diff --git a/tests/test_study.py b/tests/test_study.py index 2fd19aa4e..a6b5c7dc7 100644 --- a/tests/test_study.py +++ b/tests/test_study.py @@ -1501,6 +1501,19 @@ def test_focus_rejected_in_meta_mode(self): result = main(["ceo", "/tmp/fake", "--focus", "fix bug", "--mode", "meta"]) assert result == 1 + def test_focus_accepted_with_deep_research_mode(self): + from factory.cli._ceo_helpers import _validate_ceo_flags + import argparse + + args = argparse.Namespace( + path="/tmp/fake", mode="deep-research", focus="test topic", + headless=False, bg=False, bg_agents=False, prompt=None, + dir=None, auto_approve=False, from_plan=None, just_plan=False, + refine=None, + ) + result = _validate_ceo_flags(args) + assert not isinstance(result, int), f"Expected tuple but got error code {result}" + class TestStudyParserFocus: def test_study_parser_accepts_focus(self): diff --git a/tests/test_workflow_cli.py b/tests/test_workflow_cli.py index 9b8ac60fb..5160e985b 100644 --- a/tests/test_workflow_cli.py +++ b/tests/test_workflow_cli.py @@ -43,8 +43,10 @@ def _reset_registry(): WorkflowRegistry.reset() -def _make_args(name: str, project_path: str, dry_run: bool = False) -> argparse.Namespace: - return argparse.Namespace(name=name, project_path=project_path, dry_run=dry_run) +def _make_args( + name: str, project_path: str, dry_run: bool = False, focus: str | None = None, +) -> argparse.Namespace: + return argparse.Namespace(name=name, project_path=project_path, dry_run=dry_run, focus=focus) def _success_result() -> ExecutionResult: @@ -142,6 +144,92 @@ def test_executor_receives_correct_params(self, tmp_path: Path) -> None: dry_run=True, ) + def test_focus_injects_node_context(self, tmp_path: Path) -> None: + """--focus should populate node_context for all AgentNodes.""" + wf = Workflow( + name="test", + nodes={ + "study": Study(id="study", writes={"obs.md"}), + "researcher": AgentNode(id="researcher", role=AgentRole.RESEARCHER), + "gate": GateNode(id="gate", evaluator_type="agent", evaluator_role=AgentRole.CEO), + }, + edges=[ + Edge(source="study", target="researcher"), + Edge(source="researcher", target="gate"), + ], + start_node="study", + ) + mock_executor = MagicMock() + mock_executor.execute = AsyncMock(return_value=_success_result()) + mock_executor.node_context = {} + + with ( + patch.object(WorkflowRegistry, "get_workflow", return_value=wf), + patch("factory.workflow.cli.WorkflowExecutor", return_value=mock_executor), + patch("factory.agents.runner.begin_cycle_session", return_value=None), + patch("factory.agents.runner.complete_cycle_session"), + ): + result = _cmd_run(_make_args("test", str(tmp_path), focus="LLM safety")) + + assert result == 0 + assert "LLM safety" in mock_executor.node_context.get("researcher", "") + assert "gate" not in mock_executor.node_context + + def test_no_focus_leaves_node_context_empty(self, tmp_path: Path) -> None: + """Without --focus, node_context should not be populated.""" + mock_wf = MagicMock() + mock_executor = MagicMock() + mock_executor.execute = AsyncMock(return_value=_success_result()) + mock_executor.node_context = {} + + with ( + patch.object(WorkflowRegistry, "get_workflow", return_value=mock_wf), + patch("factory.workflow.cli.WorkflowExecutor", return_value=mock_executor), + patch("factory.agents.runner.begin_cycle_session", return_value=None), + patch("factory.agents.runner.complete_cycle_session"), + ): + result = _cmd_run(_make_args("build", str(tmp_path))) + + assert result == 0 + assert mock_executor.node_context == {} + + def test_focus_only_targets_agent_nodes(self, tmp_path: Path) -> None: + """--focus should inject context into AgentNodes, not FnNodes/GateNodes/Study.""" + wf = Workflow( + name="test", + nodes={ + "study": Study(id="study", writes={"obs.md"}), + "fn": FnNode(id="fn", command="echo hi"), + "agent1": AgentNode(id="agent1", role=AgentRole.RESEARCHER), + "agent2": AgentNode(id="agent2", role=AgentRole.STRATEGIST), + "gate": GateNode(id="gate", evaluator_type="fn", evaluator_command="true"), + }, + edges=[ + Edge(source="study", target="fn"), + Edge(source="fn", target="agent1"), + Edge(source="agent1", target="agent2"), + Edge(source="agent2", target="gate"), + ], + start_node="study", + ) + mock_executor = MagicMock() + mock_executor.execute = AsyncMock(return_value=_success_result()) + mock_executor.node_context = {} + + with ( + patch.object(WorkflowRegistry, "get_workflow", return_value=wf), + patch("factory.workflow.cli.WorkflowExecutor", return_value=mock_executor), + patch("factory.agents.runner.begin_cycle_session", return_value=None), + patch("factory.agents.runner.complete_cycle_session"), + ): + _cmd_run(_make_args("test", str(tmp_path), focus="topic X")) + + assert "topic X" in mock_executor.node_context.get("agent1", "") + assert "topic X" in mock_executor.node_context.get("agent2", "") + assert "study" not in mock_executor.node_context + assert "fn" not in mock_executor.node_context + assert "gate" not in mock_executor.node_context + # ── helpers for new tests ──────────────────────────────────────