Skip to content
Open
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
4 changes: 2 additions & 2 deletions factory/cli/_ceo_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
7 changes: 7 additions & 0 deletions factory/workflow/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)",
Expand Down
13 changes: 13 additions & 0 deletions tests/test_study.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
92 changes: 90 additions & 2 deletions tests/test_workflow_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 ──────────────────────────────────────

Expand Down
Loading