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)", 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 ──────────────────────────────────────